From c327a5d08dcb482a328c5c0e38fb25772f63d059 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 18:19:35 +0000 Subject: [PATCH 01/33] Initial plan From d921eca5a7a8eead60501596bff4777c5b03ffb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 18:24:15 +0000 Subject: [PATCH 02/33] Add Cosmos emulator integration test infrastructure and scenario plan Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/1b70d529-777e-4c18-a4a2-953806949258 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../FSharp.Azure.Cosmos.Tests.fsproj | 2 + .../Cosmos.Tests/IntegrationInfrastructure.fs | 101 ++++++++++++++++++ tests/Cosmos.Tests/IntegrationTestPlan.fs | 17 +++ 3 files changed, 120 insertions(+) create mode 100644 tests/Cosmos.Tests/IntegrationInfrastructure.fs create mode 100644 tests/Cosmos.Tests/IntegrationTestPlan.fs diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index 9588d7d..847c599 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -17,6 +17,8 @@ + + diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs new file mode 100644 index 0000000..ea4f770 --- /dev/null +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -0,0 +1,101 @@ +namespace Tests.Integration + +open System +open System.Text.RegularExpressions +open System.Threading +open System.Threading.Tasks + +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type TestBase () = + + member val TestContext = Unchecked.defaultof with get, set + + member this.CancellationToken = this.TestContext.CancellationTokenSource.Token + +type DatabaseTestApplicationFactory(testContext : TestContext) = + let endpoint = "https://127.0.0.1:8081" + + let primaryKey = + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" + + let buildDatabaseId () = + let className = + match testContext.FullyQualifiedTestClassName with + | null -> "CosmosTests" + | fullyQualifiedTestClassName when String.IsNullOrWhiteSpace fullyQualifiedTestClassName -> "CosmosTests" + | fullyQualifiedTestClassName -> fullyQualifiedTestClassName + + let sanitizedClassName = Regex.Replace(className, "[^a-zA-Z0-9-_]", "-") + let shortClassName = sanitizedClassName[..(min 40 (sanitizedClassName.Length - 1))] + $"{shortClassName}-{Guid.NewGuid():N}" + + let databaseId = buildDatabaseId () + let client = new CosmosClient(endpoint, primaryKey, CosmosClientOptions(ConnectionMode = ConnectionMode.Gateway)) + let mutable database = ValueNone + + member _.Client = client + member _.DatabaseId = databaseId + member _.Database = database + + member _.InitializeAsync(cancellationToken : CancellationToken) : Task = + task { + let! createdDatabase = client.CreateDatabaseIfNotExistsAsync(databaseId, cancellationToken = cancellationToken) + database <- ValueSome createdDatabase.Database + } + + member _.CleanupAsync(cancellationToken : CancellationToken) : Task = + task { + match database with + | ValueNone -> () + | ValueSome existingDatabase -> + let! _ = existingDatabase.DeleteAsync(cancellationToken = cancellationToken) + database <- ValueNone + } + + abstract SeedDataAsync : CancellationToken -> Task + default _.SeedDataAsync(_ : CancellationToken) = Task.CompletedTask + + interface IAsyncDisposable with + member this.DisposeAsync() = + task { + do! this.CleanupAsync(CancellationToken.None) + client.Dispose() + } + |> ValueTask + +[] +type IntegrationTestBase () = + inherit TestBase() + + [] + val mutable private application : DatabaseTestApplicationFactory voption + + member this.Application = + match this.application with + | ValueNone -> invalidOp "Integration test application is not initialized." + | ValueSome application -> application + + abstract CreateApplication : TestContext -> DatabaseTestApplicationFactory + default _.CreateApplication context = DatabaseTestApplicationFactory(context) + + [] + member this.Initialize() : Task = + task { + let application = this.CreateApplication(this.TestContext) + this.application <- ValueSome application + do! application.InitializeAsync(this.CancellationToken) + do! application.SeedDataAsync(this.CancellationToken) + } + + [] + member this.Cleanup() : Task = + task { + match this.application with + | ValueNone -> () + | ValueSome application -> + do! (application :> IAsyncDisposable).DisposeAsync().AsTask() + this.application <- ValueNone + } diff --git a/tests/Cosmos.Tests/IntegrationTestPlan.fs b/tests/Cosmos.Tests/IntegrationTestPlan.fs new file mode 100644 index 0000000..5b0c76b --- /dev/null +++ b/tests/Cosmos.Tests/IntegrationTestPlan.fs @@ -0,0 +1,17 @@ +namespace Tests.Integration + +module IntegrationTestPlan = + + let PlannedScenarios : string array = + [| + "Create item: validates id/partition key persistence and response diagnostics." + "Read item: validates successful reads and not-found responses." + "Upsert item: validates insert-then-update behavior and returned resource state." + "Replace item: validates optimistic concurrency with ETag preconditions." + "Patch item: validates additive and replace patch operations for partial updates." + "Delete item: validates deletion and subsequent not-found behavior." + "Read many: validates batch read behavior across partitions." + "Iterator/taskSeq reads: validates paging and continuation behavior." + "Unique key constraints: validates duplicate write failures." + "Seeded data scenarios: empty container, single partition set, multi-partition set." + |] From 527ea3689ef1c1529bc58e6f490866e0007a9562 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 18:28:31 +0000 Subject: [PATCH 03/33] Refine Cosmos test infrastructure and planned scenario list Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/1b70d529-777e-4c18-a4a2-953806949258 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../Cosmos.Tests/IntegrationInfrastructure.fs | 35 ++++++++++++++----- tests/Cosmos.Tests/IntegrationTestPlan.fs | 4 +-- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs index ea4f770..a871456 100644 --- a/tests/Cosmos.Tests/IntegrationInfrastructure.fs +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -16,20 +16,38 @@ type TestBase () = member this.CancellationToken = this.TestContext.CancellationTokenSource.Token type DatabaseTestApplicationFactory(testContext : TestContext) = + [] + let defaultClassName = "CosmosTests" + + [] let endpoint = "https://127.0.0.1:8081" + [] let primaryKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" + [] + let maxClassNameLength = 40 + let buildDatabaseId () = let className = match testContext.FullyQualifiedTestClassName with - | null -> "CosmosTests" - | fullyQualifiedTestClassName when String.IsNullOrWhiteSpace fullyQualifiedTestClassName -> "CosmosTests" - | fullyQualifiedTestClassName -> fullyQualifiedTestClassName + | null -> defaultClassName + | fullyQualifiedTestClassName -> + if fullyQualifiedTestClassName.Trim().Length = 0 then + defaultClassName + else + fullyQualifiedTestClassName let sanitizedClassName = Regex.Replace(className, "[^a-zA-Z0-9-_]", "-") - let shortClassName = sanitizedClassName[..(min 40 (sanitizedClassName.Length - 1))] + let validClassName = + if String.IsNullOrWhiteSpace sanitizedClassName then + defaultClassName + else + sanitizedClassName + + let maxAllowedIndex = min (maxClassNameLength - 1) (validClassName.Length - 1) + let shortClassName = validClassName[..maxAllowedIndex] $"{shortClassName}-{Guid.NewGuid():N}" let databaseId = buildDatabaseId () @@ -55,8 +73,8 @@ type DatabaseTestApplicationFactory(testContext : TestContext) = database <- ValueNone } - abstract SeedDataAsync : CancellationToken -> Task - default _.SeedDataAsync(_ : CancellationToken) = Task.CompletedTask + abstract SeedDataAsync : cancellationToken : CancellationToken -> Task + default _.SeedDataAsync(cancellationToken : CancellationToken) = Task.CompletedTask interface IAsyncDisposable with member this.DisposeAsync() = @@ -70,12 +88,11 @@ type DatabaseTestApplicationFactory(testContext : TestContext) = type IntegrationTestBase () = inherit TestBase() - [] - val mutable private application : DatabaseTestApplicationFactory voption + member val private application : DatabaseTestApplicationFactory voption = ValueNone with get, set member this.Application = match this.application with - | ValueNone -> invalidOp "Integration test application is not initialized." + | ValueNone -> invalidOp "Application not initialized. Ensure test runs within TestInitialize/TestCleanup lifecycle." | ValueSome application -> application abstract CreateApplication : TestContext -> DatabaseTestApplicationFactory diff --git a/tests/Cosmos.Tests/IntegrationTestPlan.fs b/tests/Cosmos.Tests/IntegrationTestPlan.fs index 5b0c76b..3ca1f00 100644 --- a/tests/Cosmos.Tests/IntegrationTestPlan.fs +++ b/tests/Cosmos.Tests/IntegrationTestPlan.fs @@ -2,7 +2,7 @@ namespace Tests.Integration module IntegrationTestPlan = - let PlannedScenarios : string array = + let plannedScenarios : string array = [| "Create item: validates id/partition key persistence and response diagnostics." "Read item: validates successful reads and not-found responses." @@ -11,7 +11,7 @@ module IntegrationTestPlan = "Patch item: validates additive and replace patch operations for partial updates." "Delete item: validates deletion and subsequent not-found behavior." "Read many: validates batch read behavior across partitions." - "Iterator/taskSeq reads: validates paging and continuation behavior." + "Iterator/TaskSeq reads: validates paging and continuation behavior." "Unique key constraints: validates duplicate write failures." "Seeded data scenarios: empty container, single partition set, multi-partition set." |] From f98ebe51ea21211c6d1aff5767625d476f384f7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 22:30:30 +0000 Subject: [PATCH 04/33] Remove DB name normalization and format integration files Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/6a9fbc0d-9c2b-4b57-ac0e-308e2d1edeae Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../Cosmos.Tests/IntegrationInfrastructure.fs | 88 ++++++++----------- tests/Cosmos.Tests/IntegrationTestPlan.fs | 25 +++--- 2 files changed, 48 insertions(+), 65 deletions(-) diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs index a871456..c9374c7 100644 --- a/tests/Cosmos.Tests/IntegrationInfrastructure.fs +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -1,7 +1,6 @@ namespace Tests.Integration open System -open System.Text.RegularExpressions open System.Threading open System.Threading.Tasks @@ -15,7 +14,7 @@ type TestBase () = member this.CancellationToken = this.TestContext.CancellationTokenSource.Token -type DatabaseTestApplicationFactory(testContext : TestContext) = +type DatabaseTestApplicationFactory (testContext : TestContext) = [] let defaultClassName = "CosmosTests" @@ -26,11 +25,8 @@ type DatabaseTestApplicationFactory(testContext : TestContext) = let primaryKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" - [] - let maxClassNameLength = 40 - let buildDatabaseId () = - let className = + let testClassName = match testContext.FullyQualifiedTestClassName with | null -> defaultClassName | fullyQualifiedTestClassName -> @@ -39,54 +35,44 @@ type DatabaseTestApplicationFactory(testContext : TestContext) = else fullyQualifiedTestClassName - let sanitizedClassName = Regex.Replace(className, "[^a-zA-Z0-9-_]", "-") - let validClassName = - if String.IsNullOrWhiteSpace sanitizedClassName then - defaultClassName - else - sanitizedClassName - - let maxAllowedIndex = min (maxClassNameLength - 1) (validClassName.Length - 1) - let shortClassName = validClassName[..maxAllowedIndex] - $"{shortClassName}-{Guid.NewGuid():N}" + $"{testClassName}-{Guid.NewGuid ():N}" let databaseId = buildDatabaseId () - let client = new CosmosClient(endpoint, primaryKey, CosmosClientOptions(ConnectionMode = ConnectionMode.Gateway)) + let client = + new CosmosClient (endpoint, primaryKey, CosmosClientOptions (ConnectionMode = ConnectionMode.Gateway)) let mutable database = ValueNone member _.Client = client member _.DatabaseId = databaseId member _.Database = database - member _.InitializeAsync(cancellationToken : CancellationToken) : Task = - task { - let! createdDatabase = client.CreateDatabaseIfNotExistsAsync(databaseId, cancellationToken = cancellationToken) - database <- ValueSome createdDatabase.Database - } - - member _.CleanupAsync(cancellationToken : CancellationToken) : Task = - task { - match database with - | ValueNone -> () - | ValueSome existingDatabase -> - let! _ = existingDatabase.DeleteAsync(cancellationToken = cancellationToken) - database <- ValueNone - } + member _.InitializeAsync (cancellationToken : CancellationToken) : Task = task { + let! createdDatabase = client.CreateDatabaseIfNotExistsAsync (databaseId, cancellationToken = cancellationToken) + database <- ValueSome createdDatabase.Database + } + + member _.CleanupAsync (cancellationToken : CancellationToken) : Task = task { + match database with + | ValueNone -> () + | ValueSome existingDatabase -> + let! _ = existingDatabase.DeleteAsync (cancellationToken = cancellationToken) + database <- ValueNone + } abstract SeedDataAsync : cancellationToken : CancellationToken -> Task - default _.SeedDataAsync(cancellationToken : CancellationToken) = Task.CompletedTask + default _.SeedDataAsync (cancellationToken : CancellationToken) = Task.CompletedTask interface IAsyncDisposable with - member this.DisposeAsync() = + member this.DisposeAsync () = task { - do! this.CleanupAsync(CancellationToken.None) - client.Dispose() + do! this.CleanupAsync (CancellationToken.None) + client.Dispose () } |> ValueTask [] type IntegrationTestBase () = - inherit TestBase() + inherit TestBase () member val private application : DatabaseTestApplicationFactory voption = ValueNone with get, set @@ -96,23 +82,21 @@ type IntegrationTestBase () = | ValueSome application -> application abstract CreateApplication : TestContext -> DatabaseTestApplicationFactory - default _.CreateApplication context = DatabaseTestApplicationFactory(context) + default _.CreateApplication context = DatabaseTestApplicationFactory (context) [] - member this.Initialize() : Task = - task { - let application = this.CreateApplication(this.TestContext) - this.application <- ValueSome application - do! application.InitializeAsync(this.CancellationToken) - do! application.SeedDataAsync(this.CancellationToken) - } + member this.Initialize () : Task = task { + let application = this.CreateApplication (this.TestContext) + this.application <- ValueSome application + do! application.InitializeAsync (this.CancellationToken) + do! application.SeedDataAsync (this.CancellationToken) + } [] - member this.Cleanup() : Task = - task { - match this.application with - | ValueNone -> () - | ValueSome application -> - do! (application :> IAsyncDisposable).DisposeAsync().AsTask() - this.application <- ValueNone - } + member this.Cleanup () : Task = task { + match this.application with + | ValueNone -> () + | ValueSome application -> + do! (application :> IAsyncDisposable).DisposeAsync().AsTask () + this.application <- ValueNone + } diff --git a/tests/Cosmos.Tests/IntegrationTestPlan.fs b/tests/Cosmos.Tests/IntegrationTestPlan.fs index 3ca1f00..d74b934 100644 --- a/tests/Cosmos.Tests/IntegrationTestPlan.fs +++ b/tests/Cosmos.Tests/IntegrationTestPlan.fs @@ -2,16 +2,15 @@ namespace Tests.Integration module IntegrationTestPlan = - let plannedScenarios : string array = - [| - "Create item: validates id/partition key persistence and response diagnostics." - "Read item: validates successful reads and not-found responses." - "Upsert item: validates insert-then-update behavior and returned resource state." - "Replace item: validates optimistic concurrency with ETag preconditions." - "Patch item: validates additive and replace patch operations for partial updates." - "Delete item: validates deletion and subsequent not-found behavior." - "Read many: validates batch read behavior across partitions." - "Iterator/TaskSeq reads: validates paging and continuation behavior." - "Unique key constraints: validates duplicate write failures." - "Seeded data scenarios: empty container, single partition set, multi-partition set." - |] + let plannedScenarios : string array = [| + "Create item: validates id/partition key persistence and response diagnostics." + "Read item: validates successful reads and not-found responses." + "Upsert item: validates insert-then-update behavior and returned resource state." + "Replace item: validates optimistic concurrency with ETag preconditions." + "Patch item: validates additive and replace patch operations for partial updates." + "Delete item: validates deletion and subsequent not-found behavior." + "Read many: validates batch read behavior across partitions." + "Iterator/TaskSeq reads: validates paging and continuation behavior." + "Unique key constraints: validates duplicate write failures." + "Seeded data scenarios: empty container, single partition set, multi-partition set." + |] From 67c9905b8d03a8f6b0fb3880ef4e64e729eee7ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 00:14:57 +0000 Subject: [PATCH 05/33] Use TestContext-based database identifier for integration tests Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/ed2b3897-981f-4bb6-afae-f9e0089c252a Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../Cosmos.Tests/IntegrationInfrastructure.fs | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs index c9374c7..9e541f1 100644 --- a/tests/Cosmos.Tests/IntegrationInfrastructure.fs +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -7,6 +7,31 @@ open System.Threading.Tasks open Microsoft.Azure.Cosmos open Microsoft.VisualStudio.TestTools.UnitTesting +[] +module TestContextExtensions = + + type TestContext with + + member ctx.GetTestDatabaseIdentifier () = + match ctx.TestData with + | null -> ctx.TestName + | testData -> + let dataHash = + testData + |> Array.fold + (fun acc item -> + let itemHash = + match item with + | null -> 0 + | item -> item.GetHashCode () + + HashCode.Combine (acc, itemHash) + ) + 0 + |> abs + + $"{ctx.TestName}_{dataHash}" + [] type TestBase () = @@ -15,9 +40,6 @@ type TestBase () = member this.CancellationToken = this.TestContext.CancellationTokenSource.Token type DatabaseTestApplicationFactory (testContext : TestContext) = - [] - let defaultClassName = "CosmosTests" - [] let endpoint = "https://127.0.0.1:8081" @@ -25,17 +47,7 @@ type DatabaseTestApplicationFactory (testContext : TestContext) = let primaryKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" - let buildDatabaseId () = - let testClassName = - match testContext.FullyQualifiedTestClassName with - | null -> defaultClassName - | fullyQualifiedTestClassName -> - if fullyQualifiedTestClassName.Trim().Length = 0 then - defaultClassName - else - fullyQualifiedTestClassName - - $"{testClassName}-{Guid.NewGuid ():N}" + let buildDatabaseId () = testContext.GetTestDatabaseIdentifier () let databaseId = buildDatabaseId () let client = From 44baaefa566335aef741bb21da99e2a2621cb436 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 00:15:43 +0000 Subject: [PATCH 06/33] Harden test data hash conversion for database identifier Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/ed2b3897-981f-4bb6-afae-f9e0089c252a Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/IntegrationInfrastructure.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs index 9e541f1..099a6c1 100644 --- a/tests/Cosmos.Tests/IntegrationInfrastructure.fs +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -28,6 +28,7 @@ module TestContextExtensions = HashCode.Combine (acc, itemHash) ) 0 + |> int64 |> abs $"{ctx.TestName}_{dataHash}" From e177988dae431f68a25f0953e585ba47db43756b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 01:00:34 +0000 Subject: [PATCH 07/33] Add integration tests for create/read/upsert/replace/patch/delete/readmany operations Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/c46a3cd3-c537-46ff-a93f-15d1ad4d611d Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/Tests.fs | 299 +++++++++++++++++++++++++++++++++++- 1 file changed, 296 insertions(+), 3 deletions(-) diff --git a/tests/Cosmos.Tests/Tests.fs b/tests/Cosmos.Tests/Tests.fs index 7afcb8b..83f9f0a 100644 --- a/tests/Cosmos.Tests/Tests.fs +++ b/tests/Cosmos.Tests/Tests.fs @@ -1,10 +1,303 @@ -namespace Tests +namespace Tests.Integration open System +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.Azure.Cosmos open Microsoft.VisualStudio.TestTools.UnitTesting +type private TestItem = { id : string; partitionKey : string; name : string; quantity : int } + [] -type TestClass () = +type OperationIntegrationTests () = + inherit IntegrationTestBase () + + [] + let containerId = "operation-tests" + + member private this.GetContainerAsync () : Task = task { + let database = + match this.Application.Database with + | ValueSome database -> database + | ValueNone -> invalidOp "Database is not initialized." + + let! containerResponse = + database.CreateContainerIfNotExistsAsync ( + ContainerProperties (containerId, "/partitionKey"), + cancellationToken = this.CancellationToken + ) + + return containerResponse.Container + } + + member private this.NewItem (suffix : string) : TestItem = { + id = $"{this.TestContext.TestName}-{suffix}" + partitionKey = "integration" + name = $"item-{suffix}" + quantity = 1 + } + + [] + member this.Create_execute_returns_created_resource () : Task = task { + let! container = this.GetContainerAsync () + let testItem = this.NewItem "create" + + let! response = + container.ExecuteAsync ( + createAndRead { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match response.Result with + | CreateResult.Ok created -> + Assert.IsTrue (testItem.id = created.id, "Create should persist the item id.") + Assert.IsTrue (testItem.partitionKey = created.partitionKey, "Create should persist the item partition key.") + Assert.IsFalse (String.IsNullOrWhiteSpace response.ActivityId, "Create should return a valid activity id.") + Assert.IsTrue (response.RequestCharge > 0.0, "Create should report positive request charge.") + | result -> Assert.Fail ($"Expected create success but received {result}.") + } + + [] + member this.Read_execute_returns_existing_and_not_found_states () : Task = task { + let! container = this.GetContainerAsync () + let testItem = this.NewItem "read" + + let! _ = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let! foundResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match foundResponse.Result with + | ReadResult.Ok found -> + Assert.IsTrue (testItem.id = found.id, "Read should return the item that was created.") + Assert.IsTrue (foundResponse.HttpStatusCode = HttpStatusCode.OK, "Read success should return HTTP 200.") + | result -> Assert.Fail ($"Expected successful read but received {result}.") + + let missingId = $"{testItem.id}-missing" + + let! missingResponse = + container.ExecuteAsync ( + read { + id missingId + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match missingResponse.Result with + | ReadResult.NotFound _ -> + Assert.IsTrue ( + missingResponse.HttpStatusCode = HttpStatusCode.NotFound, + "Read of missing item should return HTTP 404." + ) + | result -> Assert.Fail ($"Expected read not found for missing item but received {result}.") + } [] - member this.TestMethodPassing () = Assert.IsTrue (true) + member this.Upsert_execute_overwrite_creates_then_updates_item () : Task = task { + let! container = this.GetContainerAsync () + let testItem = this.NewItem "upsert" + + let! createdResponse = + container.ExecuteOverwriteAsync ( + upsertAndRead { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match createdResponse.Result with + | UpsertResult.Ok created -> + Assert.IsTrue (testItem.name = created.name, "Upsert create should store initial payload.") + Assert.IsTrue (createdResponse.HttpStatusCode = HttpStatusCode.Created, "Initial upsert should create item.") + | result -> Assert.Fail ($"Expected upsert create success but received {result}.") + + let updated = { testItem with name = "item-upsert-updated"; quantity = 5 } + + let! updatedResponse = + container.ExecuteOverwriteAsync ( + upsertAndRead { + item updated + partitionKey updated.partitionKey + }, + this.CancellationToken + ) + + match updatedResponse.Result with + | UpsertResult.Ok upserted -> + Assert.IsTrue (updated.name = upserted.name, "Upsert update should persist new payload.") + Assert.IsTrue (updated.quantity = upserted.quantity, "Upsert update should persist new quantity.") + Assert.IsTrue (updatedResponse.HttpStatusCode = HttpStatusCode.OK, "Second upsert should replace existing item.") + | result -> Assert.Fail ($"Expected upsert update success but received {result}.") + } + + [] + member this.Replace_execute_overwrite_replaces_existing_item () : Task = task { + let! container = this.GetContainerAsync () + let testItem = this.NewItem "replace" + + let! _ = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let replacement = { testItem with name = "item-replaced"; quantity = 3 } + + let! replaceResponse = + container.ExecuteOverwriteAsync ( + replaceAndRead { + id replacement.id + item replacement + partitionKey replacement.partitionKey + }, + this.CancellationToken + ) + + match replaceResponse.Result with + | ReplaceResult.Ok replaced -> + Assert.IsTrue (replacement.name = replaced.name, "Replace should persist replacement name.") + Assert.IsTrue (replacement.quantity = replaced.quantity, "Replace should persist replacement quantity.") + Assert.IsTrue (replaceResponse.HttpStatusCode = HttpStatusCode.OK, "Replace should return HTTP 200.") + | result -> Assert.Fail ($"Expected replace success but received {result}.") + } + + [] + member this.Patch_execute_overwrite_updates_targeted_field () : Task = task { + let! container = this.GetContainerAsync () + let testItem = this.NewItem "patch" + + let! _ = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let! patchResponse = + container.ExecuteOverwriteAsync ( + patchAndRead { + id testItem.id + partitionKey testItem.partitionKey + operation (Microsoft.Azure.Cosmos.PatchOperation.Replace ("/name", "item-patched")) + operation (Microsoft.Azure.Cosmos.PatchOperation.Replace ("/quantity", 9)) + }, + this.CancellationToken + ) + + match patchResponse.Result with + | PatchResult.Ok patched -> + Assert.IsTrue ("item-patched" = patched.name, "Patch should update the name field.") + Assert.IsTrue (9 = patched.quantity, "Patch should update the quantity field.") + Assert.IsTrue (patchResponse.HttpStatusCode = HttpStatusCode.OK, "Patch should return HTTP 200.") + | result -> Assert.Fail ($"Expected patch success but received {result}.") + } + + [] + member this.Delete_execute_removes_item_and_subsequent_read_is_not_found () : Task = task { + let! container = this.GetContainerAsync () + let testItem = this.NewItem "delete" + + let! _ = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let! deleteResponse = + container.ExecuteAsync ( + delete { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match deleteResponse.Result with + | DeleteResult.Ok _ -> + Assert.IsTrue (deleteResponse.HttpStatusCode = HttpStatusCode.NoContent, "Delete should return HTTP 204.") + | result -> Assert.Fail ($"Expected delete success but received {result}.") + + let! missingResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match missingResponse.Result with + | ReadResult.NotFound _ -> + Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read after delete should return HTTP 404.") + | result -> Assert.Fail ($"Expected read not found after delete but received {result}.") + } + + [] + member this.ReadMany_execute_returns_matching_items () : Task = task { + let! container = this.GetContainerAsync () + let firstItem = this.NewItem "readmany-1" + let secondItem = this.NewItem "readmany-2" + + let! _ = + container.ExecuteAsync ( + create { + item firstItem + partitionKey firstItem.partitionKey + }, + this.CancellationToken + ) + + let! _ = + container.ExecuteAsync ( + create { + item secondItem + partitionKey secondItem.partitionKey + }, + this.CancellationToken + ) + + let! readManyResponse = + container.ExecuteAsync ( + readMany { + item firstItem.id firstItem.partitionKey + item secondItem.id secondItem.partitionKey + }, + this.CancellationToken + ) + + match readManyResponse.Result with + | ReadManyResult.Ok (feed : FeedResponse) -> + let returnedIds = feed |> Seq.map _.id |> Set.ofSeq + Assert.IsTrue (feed.Count = 2, "ReadMany should return the requested number of existing items.") + Assert.IsTrue (returnedIds.Contains firstItem.id, "ReadMany should include first requested item.") + Assert.IsTrue (returnedIds.Contains secondItem.id, "ReadMany should include second requested item.") + Assert.IsTrue (readManyResponse.HttpStatusCode = HttpStatusCode.OK, "ReadMany should return HTTP 200.") + | result -> Assert.Fail ($"Expected read many success but received {result}.") + } From 7d70871929efefe4c3cd2b6fa992068f32129eb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 01:01:46 +0000 Subject: [PATCH 08/33] Address integration test review feedback in patch scenario Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/c46a3cd3-c537-46ff-a93f-15d1ad4d611d Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/Tests.fs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/Cosmos.Tests/Tests.fs b/tests/Cosmos.Tests/Tests.fs index 83f9f0a..26f1bc3 100644 --- a/tests/Cosmos.Tests/Tests.fs +++ b/tests/Cosmos.Tests/Tests.fs @@ -13,7 +13,6 @@ type private TestItem = { id : string; partitionKey : string; name : string; qua type OperationIntegrationTests () = inherit IntegrationTestBase () - [] let containerId = "operation-tests" member private this.GetContainerAsync () : Task = task { @@ -187,6 +186,8 @@ type OperationIntegrationTests () = member this.Patch_execute_overwrite_updates_targeted_field () : Task = task { let! container = this.GetContainerAsync () let testItem = this.NewItem "patch" + let patchedName = "item-patched" + let patchedQuantity = 9 let! _ = container.ExecuteAsync ( @@ -202,16 +203,18 @@ type OperationIntegrationTests () = patchAndRead { id testItem.id partitionKey testItem.partitionKey - operation (Microsoft.Azure.Cosmos.PatchOperation.Replace ("/name", "item-patched")) - operation (Microsoft.Azure.Cosmos.PatchOperation.Replace ("/quantity", 9)) + operation (Microsoft.Azure.Cosmos.PatchOperation.Replace ("/name", patchedName)) + operation (Microsoft.Azure.Cosmos.PatchOperation.Replace ("/quantity", patchedQuantity)) }, this.CancellationToken ) match patchResponse.Result with | PatchResult.Ok patched -> - Assert.IsTrue ("item-patched" = patched.name, "Patch should update the name field.") - Assert.IsTrue (9 = patched.quantity, "Patch should update the quantity field.") + let isNamePatched = patchedName = patched.name + let isQuantityPatched = patchedQuantity = patched.quantity + Assert.IsTrue (isNamePatched, "Patch should update the name field.") + Assert.IsTrue (isQuantityPatched, "Patch should update the quantity field.") Assert.IsTrue (patchResponse.HttpStatusCode = HttpStatusCode.OK, "Patch should return HTTP 200.") | result -> Assert.Fail ($"Expected patch success but received {result}.") } From 660a0cc0db7aafb0e2fc986f997cd272b0f9cbee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 01:15:19 +0000 Subject: [PATCH 09/33] Add CosmosAssert helper and simplify integration test assertions Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/727cc9a9-e850-45de-b65f-4e8eb8ff33fc Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/CosmosAssert.fs | 90 +++++++++++++++++++ .../FSharp.Azure.Cosmos.Tests.fsproj | 1 + tests/Cosmos.Tests/Tests.fs | 70 +++++++-------- 3 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 tests/Cosmos.Tests/CosmosAssert.fs diff --git a/tests/Cosmos.Tests/CosmosAssert.fs b/tests/Cosmos.Tests/CosmosAssert.fs new file mode 100644 index 0000000..5b7d2ac --- /dev/null +++ b/tests/Cosmos.Tests/CosmosAssert.fs @@ -0,0 +1,90 @@ +namespace Tests.Integration + +open System.Diagnostics +open System.Net +open System.Runtime.InteropServices +open FSharp.Azure.Cosmos.Create +open FSharp.Azure.Cosmos.Delete +open FSharp.Azure.Cosmos.Read +open FSharp.Azure.Cosmos.Replace +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type CosmosAssert private () = + + static member WantOk<'T> (response : ItemResponse<'T>, [] message) = + match response.StatusCode with + | HttpStatusCode.OK + | HttpStatusCode.Created -> response.Resource + | _ -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsOk (response : ItemResponse<'T>, [] message) = CosmosAssert.WantOk (response, message) |> ignore + + static member WantOk<'T> (result : CreateResult<'T>, [] message) = + match result with + | CreateResult.Ok ok -> ok + | _ -> + Assert.Fail ($"Expected CreateResult.Ok, but {result} given. {message}") + Unchecked.defaultof<_> + + static member IsOk (result : CreateResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore + + static member WantOk<'T> (result : ReadResult<'T>, [] message) = + match result with + | ReadResult.Ok ok -> ok + | _ -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsOk (result : ReadResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore + + static member WantOk<'T> (result : ReplaceResult<'T>, [] message) = + match result with + | ReplaceResult.Ok ok -> ok + | _ -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsOk (result : ReplaceResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore + + static member WantOk<'T> (result : DeleteResult<'T>, [] message) = + match result with + | DeleteResult.Ok ok -> ok + | _ -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsOk (result : DeleteResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore + + static member WantNotFound<'T> (result : ReadResult<'T>, [] message) = + match result with + | ReadResult.NotFound response -> response + | _ -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsNotFound (result : ReadResult<'T>, [] message) = + CosmosAssert.WantNotFound (result, message) |> ignore + + static member WantNotFound<'T> (result : DeleteResult<'T>, [] message) = + match result with + | DeleteResult.NotFound response -> response + | _ -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsNotFound (result : DeleteResult<'T>, [] message) = + CosmosAssert.WantNotFound (result, message) |> ignore + + static member WantConflict (result : CreateResult<'T>, [] message) = + match result with + | CreateResult.IdAlreadyExists _ -> () + | _ -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsConflict (result : CreateResult<'T>, [] message) = + CosmosAssert.WantConflict (result, message) |> ignore diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index 847c599..e306248 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -18,6 +18,7 @@ + diff --git a/tests/Cosmos.Tests/Tests.fs b/tests/Cosmos.Tests/Tests.fs index 26f1bc3..54689ef 100644 --- a/tests/Cosmos.Tests/Tests.fs +++ b/tests/Cosmos.Tests/Tests.fs @@ -51,13 +51,11 @@ type OperationIntegrationTests () = this.CancellationToken ) - match response.Result with - | CreateResult.Ok created -> - Assert.IsTrue (testItem.id = created.id, "Create should persist the item id.") - Assert.IsTrue (testItem.partitionKey = created.partitionKey, "Create should persist the item partition key.") - Assert.IsFalse (String.IsNullOrWhiteSpace response.ActivityId, "Create should return a valid activity id.") - Assert.IsTrue (response.RequestCharge > 0.0, "Create should report positive request charge.") - | result -> Assert.Fail ($"Expected create success but received {result}.") + let created = CosmosAssert.WantOk (response.Result, "Create should return CreateResult.Ok.") + Assert.IsTrue (testItem.id = created.id, "Create should persist the item id.") + Assert.IsTrue (testItem.partitionKey = created.partitionKey, "Create should persist the item partition key.") + Assert.IsFalse (String.IsNullOrWhiteSpace response.ActivityId, "Create should return a valid activity id.") + Assert.IsTrue (response.RequestCharge > 0.0, "Create should report positive request charge.") } [] @@ -65,7 +63,7 @@ type OperationIntegrationTests () = let! container = this.GetContainerAsync () let testItem = this.NewItem "read" - let! _ = + let! createdResponse = container.ExecuteAsync ( create { item testItem @@ -73,6 +71,7 @@ type OperationIntegrationTests () = }, this.CancellationToken ) + CosmosAssert.IsOk (createdResponse.Result, "Seed create should succeed.") let! foundResponse = container.ExecuteAsync ( @@ -83,11 +82,10 @@ type OperationIntegrationTests () = this.CancellationToken ) - match foundResponse.Result with - | ReadResult.Ok found -> - Assert.IsTrue (testItem.id = found.id, "Read should return the item that was created.") - Assert.IsTrue (foundResponse.HttpStatusCode = HttpStatusCode.OK, "Read success should return HTTP 200.") - | result -> Assert.Fail ($"Expected successful read but received {result}.") + let found = + CosmosAssert.WantOk (foundResponse.Result, "Read should return ReadResult.Ok for existing item.") + Assert.IsTrue (testItem.id = found.id, "Read should return the item that was created.") + Assert.IsTrue (foundResponse.HttpStatusCode = HttpStatusCode.OK, "Read success should return HTTP 200.") let missingId = $"{testItem.id}-missing" @@ -100,13 +98,8 @@ type OperationIntegrationTests () = this.CancellationToken ) - match missingResponse.Result with - | ReadResult.NotFound _ -> - Assert.IsTrue ( - missingResponse.HttpStatusCode = HttpStatusCode.NotFound, - "Read of missing item should return HTTP 404." - ) - | result -> Assert.Fail ($"Expected read not found for missing item but received {result}.") + CosmosAssert.IsNotFound (missingResponse.Result, "Read of missing item should return ReadResult.NotFound.") + Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read of missing item should return HTTP 404.") } [] @@ -153,7 +146,7 @@ type OperationIntegrationTests () = let! container = this.GetContainerAsync () let testItem = this.NewItem "replace" - let! _ = + let! createdResponse = container.ExecuteAsync ( create { item testItem @@ -161,6 +154,7 @@ type OperationIntegrationTests () = }, this.CancellationToken ) + CosmosAssert.IsOk (createdResponse.Result, "Seed create should succeed.") let replacement = { testItem with name = "item-replaced"; quantity = 3 } @@ -174,12 +168,10 @@ type OperationIntegrationTests () = this.CancellationToken ) - match replaceResponse.Result with - | ReplaceResult.Ok replaced -> - Assert.IsTrue (replacement.name = replaced.name, "Replace should persist replacement name.") - Assert.IsTrue (replacement.quantity = replaced.quantity, "Replace should persist replacement quantity.") - Assert.IsTrue (replaceResponse.HttpStatusCode = HttpStatusCode.OK, "Replace should return HTTP 200.") - | result -> Assert.Fail ($"Expected replace success but received {result}.") + let replaced = CosmosAssert.WantOk (replaceResponse.Result, "Replace should return ReplaceResult.Ok.") + Assert.IsTrue (replacement.name = replaced.name, "Replace should persist replacement name.") + Assert.IsTrue (replacement.quantity = replaced.quantity, "Replace should persist replacement quantity.") + Assert.IsTrue (replaceResponse.HttpStatusCode = HttpStatusCode.OK, "Replace should return HTTP 200.") } [] @@ -189,7 +181,7 @@ type OperationIntegrationTests () = let patchedName = "item-patched" let patchedQuantity = 9 - let! _ = + let! createdResponse = container.ExecuteAsync ( create { item testItem @@ -197,6 +189,7 @@ type OperationIntegrationTests () = }, this.CancellationToken ) + CosmosAssert.IsOk (createdResponse.Result, "Seed create should succeed.") let! patchResponse = container.ExecuteOverwriteAsync ( @@ -224,7 +217,7 @@ type OperationIntegrationTests () = let! container = this.GetContainerAsync () let testItem = this.NewItem "delete" - let! _ = + let! createdResponse = container.ExecuteAsync ( create { item testItem @@ -232,6 +225,7 @@ type OperationIntegrationTests () = }, this.CancellationToken ) + CosmosAssert.IsOk (createdResponse.Result, "Seed create should succeed.") let! deleteResponse = container.ExecuteAsync ( @@ -242,10 +236,8 @@ type OperationIntegrationTests () = this.CancellationToken ) - match deleteResponse.Result with - | DeleteResult.Ok _ -> - Assert.IsTrue (deleteResponse.HttpStatusCode = HttpStatusCode.NoContent, "Delete should return HTTP 204.") - | result -> Assert.Fail ($"Expected delete success but received {result}.") + CosmosAssert.IsOk (deleteResponse.Result, "Delete should return DeleteResult.Ok.") + Assert.IsTrue (deleteResponse.HttpStatusCode = HttpStatusCode.NoContent, "Delete should return HTTP 204.") let! missingResponse = container.ExecuteAsync ( @@ -256,10 +248,8 @@ type OperationIntegrationTests () = this.CancellationToken ) - match missingResponse.Result with - | ReadResult.NotFound _ -> - Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read after delete should return HTTP 404.") - | result -> Assert.Fail ($"Expected read not found after delete but received {result}.") + CosmosAssert.IsNotFound (missingResponse.Result, "Read after delete should return ReadResult.NotFound.") + Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read after delete should return HTTP 404.") } [] @@ -268,7 +258,7 @@ type OperationIntegrationTests () = let firstItem = this.NewItem "readmany-1" let secondItem = this.NewItem "readmany-2" - let! _ = + let! firstCreatedResponse = container.ExecuteAsync ( create { item firstItem @@ -276,8 +266,9 @@ type OperationIntegrationTests () = }, this.CancellationToken ) + CosmosAssert.IsOk (firstCreatedResponse.Result, "First seed create should succeed.") - let! _ = + let! secondCreatedResponse = container.ExecuteAsync ( create { item secondItem @@ -285,6 +276,7 @@ type OperationIntegrationTests () = }, this.CancellationToken ) + CosmosAssert.IsOk (secondCreatedResponse.Result, "Second seed create should succeed.") let! readManyResponse = container.ExecuteAsync ( From 771f88d10a7baed1be0c576057a3c518436613f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 01:16:10 +0000 Subject: [PATCH 10/33] Refine CosmosAssert failure handling consistency Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/727cc9a9-e850-45de-b65f-4e8eb8ff33fc Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/CosmosAssert.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Cosmos.Tests/CosmosAssert.fs b/tests/Cosmos.Tests/CosmosAssert.fs index 5b7d2ac..e3336e2 100644 --- a/tests/Cosmos.Tests/CosmosAssert.fs +++ b/tests/Cosmos.Tests/CosmosAssert.fs @@ -27,7 +27,7 @@ type CosmosAssert private () = match result with | CreateResult.Ok ok -> ok | _ -> - Assert.Fail ($"Expected CreateResult.Ok, but {result} given. {message}") + Assert.Fail (message) Unchecked.defaultof<_> static member IsOk (result : CreateResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore @@ -84,7 +84,7 @@ type CosmosAssert private () = | CreateResult.IdAlreadyExists _ -> () | _ -> Assert.Fail (message) - Unchecked.defaultof<_> + () static member IsConflict (result : CreateResult<'T>, [] message) = CosmosAssert.WantConflict (result, message) |> ignore From f9d3251e69aaccd40499e4cb1fa9b3f23e25000e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 01:17:38 +0000 Subject: [PATCH 11/33] Plan split operation tests into separate files with AndRead coverage Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/727cc9a9-e850-45de-b65f-4e8eb8ff33fc Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/CosmosAssert.fs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/Cosmos.Tests/CosmosAssert.fs b/tests/Cosmos.Tests/CosmosAssert.fs index e3336e2..2dc7274 100644 --- a/tests/Cosmos.Tests/CosmosAssert.fs +++ b/tests/Cosmos.Tests/CosmosAssert.fs @@ -1,5 +1,6 @@ namespace Tests.Integration +open System open System.Diagnostics open System.Net open System.Runtime.InteropServices @@ -12,13 +13,18 @@ open Microsoft.VisualStudio.TestTools.UnitTesting [] type CosmosAssert private () = + static member private GetMessageOrDefault (message : string) (defaultMessage : string) = + if String.IsNullOrWhiteSpace message then + defaultMessage + else + message static member WantOk<'T> (response : ItemResponse<'T>, [] message) = match response.StatusCode with | HttpStatusCode.OK | HttpStatusCode.Created -> response.Resource | _ -> - Assert.Fail (message) + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected OK or Created but got {response.StatusCode}.") Unchecked.defaultof<_> static member IsOk (response : ItemResponse<'T>, [] message) = CosmosAssert.WantOk (response, message) |> ignore @@ -27,7 +33,7 @@ type CosmosAssert private () = match result with | CreateResult.Ok ok -> ok | _ -> - Assert.Fail (message) + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected CreateResult.Ok but got {result}.") Unchecked.defaultof<_> static member IsOk (result : CreateResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore @@ -36,7 +42,7 @@ type CosmosAssert private () = match result with | ReadResult.Ok ok -> ok | _ -> - Assert.Fail (message) + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected ReadResult.Ok but got {result}.") Unchecked.defaultof<_> static member IsOk (result : ReadResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore @@ -45,7 +51,7 @@ type CosmosAssert private () = match result with | ReplaceResult.Ok ok -> ok | _ -> - Assert.Fail (message) + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected ReplaceResult.Ok but got {result}.") Unchecked.defaultof<_> static member IsOk (result : ReplaceResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore @@ -54,7 +60,7 @@ type CosmosAssert private () = match result with | DeleteResult.Ok ok -> ok | _ -> - Assert.Fail (message) + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected DeleteResult.Ok but got {result}.") Unchecked.defaultof<_> static member IsOk (result : DeleteResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore @@ -63,7 +69,7 @@ type CosmosAssert private () = match result with | ReadResult.NotFound response -> response | _ -> - Assert.Fail (message) + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected ReadResult.NotFound but got {result}.") Unchecked.defaultof<_> static member IsNotFound (result : ReadResult<'T>, [] message) = @@ -73,7 +79,7 @@ type CosmosAssert private () = match result with | DeleteResult.NotFound response -> response | _ -> - Assert.Fail (message) + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected DeleteResult.NotFound but got {result}.") Unchecked.defaultof<_> static member IsNotFound (result : DeleteResult<'T>, [] message) = @@ -82,9 +88,7 @@ type CosmosAssert private () = static member WantConflict (result : CreateResult<'T>, [] message) = match result with | CreateResult.IdAlreadyExists _ -> () - | _ -> - Assert.Fail (message) - () + | _ -> Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected CreateResult.IdAlreadyExists but got {result}.") static member IsConflict (result : CreateResult<'T>, [] message) = CosmosAssert.WantConflict (result, message) |> ignore From 6d5d59f9f2393ac96d1fa0030a65c4ac09427002 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 01:19:55 +0000 Subject: [PATCH 12/33] Split operation integration tests into per-operation files with AndRead coverage Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/727cc9a9-e850-45de-b65f-4e8eb8ff33fc Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/CreateOperationTests.fs | 60 ++++ tests/Cosmos.Tests/DeleteOperationTests.fs | 51 +++ .../FSharp.Azure.Cosmos.Tests.fsproj | 9 + .../OperationTestInfrastructure.fs | 38 +++ tests/Cosmos.Tests/PatchOperationTests.fs | 101 ++++++ tests/Cosmos.Tests/ReadManyOperationTests.fs | 58 ++++ tests/Cosmos.Tests/ReadOperationTests.fs | 53 ++++ tests/Cosmos.Tests/ReplaceOperationTests.fs | 90 ++++++ tests/Cosmos.Tests/Tests.fs | 298 ------------------ tests/Cosmos.Tests/UpsertOperationTests.fs | 101 ++++++ 10 files changed, 561 insertions(+), 298 deletions(-) create mode 100644 tests/Cosmos.Tests/CreateOperationTests.fs create mode 100644 tests/Cosmos.Tests/DeleteOperationTests.fs create mode 100644 tests/Cosmos.Tests/OperationTestInfrastructure.fs create mode 100644 tests/Cosmos.Tests/PatchOperationTests.fs create mode 100644 tests/Cosmos.Tests/ReadManyOperationTests.fs create mode 100644 tests/Cosmos.Tests/ReadOperationTests.fs create mode 100644 tests/Cosmos.Tests/ReplaceOperationTests.fs delete mode 100644 tests/Cosmos.Tests/Tests.fs create mode 100644 tests/Cosmos.Tests/UpsertOperationTests.fs diff --git a/tests/Cosmos.Tests/CreateOperationTests.fs b/tests/Cosmos.Tests/CreateOperationTests.fs new file mode 100644 index 0000000..df76a1a --- /dev/null +++ b/tests/Cosmos.Tests/CreateOperationTests.fs @@ -0,0 +1,60 @@ +namespace Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type CreateOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.Create_execute_persists_item () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "create" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Create should return CreateResult.Ok.") + Assert.IsTrue (createResponse.HttpStatusCode = HttpStatusCode.Created, "Create should return HTTP 201.") + + let! readResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let created = CosmosAssert.WantOk (readResponse.Result, "Created item should be readable.") + Assert.IsTrue (testItem.id = created.id, "Create should persist item id.") + Assert.IsTrue (testItem.partitionKey = created.partitionKey, "Create should persist partition key.") + } + + [] + member this.CreateAndRead_execute_returns_created_resource () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "create-and-read" + + let! response = + container.ExecuteAsync ( + createAndRead { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let created = CosmosAssert.WantOk (response.Result, "CreateAndRead should return CreateResult.Ok.") + Assert.IsTrue (testItem.id = created.id, "CreateAndRead should return created item id.") + Assert.IsTrue (testItem.partitionKey = created.partitionKey, "CreateAndRead should return created partition key.") + } diff --git a/tests/Cosmos.Tests/DeleteOperationTests.fs b/tests/Cosmos.Tests/DeleteOperationTests.fs new file mode 100644 index 0000000..c633b81 --- /dev/null +++ b/tests/Cosmos.Tests/DeleteOperationTests.fs @@ -0,0 +1,51 @@ +namespace Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type DeleteOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.Delete_execute_removes_item_and_subsequent_read_is_not_found () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "delete" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let! deleteResponse = + container.ExecuteAsync ( + delete { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (deleteResponse.Result, "Delete should return DeleteResult.Ok.") + Assert.IsTrue (deleteResponse.HttpStatusCode = HttpStatusCode.NoContent, "Delete should return HTTP 204.") + + let! missingResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsNotFound (missingResponse.Result, "Read after delete should return ReadResult.NotFound.") + Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read after delete should return HTTP 404.") + } diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index e306248..cc8cae0 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -19,8 +19,17 @@ + + + + + + + + + diff --git a/tests/Cosmos.Tests/OperationTestInfrastructure.fs b/tests/Cosmos.Tests/OperationTestInfrastructure.fs new file mode 100644 index 0000000..9668e05 --- /dev/null +++ b/tests/Cosmos.Tests/OperationTestInfrastructure.fs @@ -0,0 +1,38 @@ +namespace Tests.Integration + +open System +open System.Threading.Tasks +open Microsoft.Azure.Cosmos + +type TestItem = { id : string; partitionKey : string; name : string; quantity : int } + +[] +type OperationTestBase () = + inherit IntegrationTestBase () + + [] + let containerId = "operation-tests" + + member private this.GetDatabase () = + match this.Application.Database with + | ValueSome database -> database + | ValueNone -> invalidOp "Database is not initialized." + + member this.GetContainer () : Task = task { + let database = this.GetDatabase () + + let! containerResponse = + database.CreateContainerIfNotExistsAsync ( + ContainerProperties (containerId, "/partitionKey"), + cancellationToken = this.CancellationToken + ) + + return containerResponse.Container + } + + member this.NewItem (suffix : string) : TestItem = { + id = $"{this.TestContext.TestName}-{suffix}" + partitionKey = "integration" + name = $"item-{suffix}" + quantity = 1 + } diff --git a/tests/Cosmos.Tests/PatchOperationTests.fs b/tests/Cosmos.Tests/PatchOperationTests.fs new file mode 100644 index 0000000..d57ee12 --- /dev/null +++ b/tests/Cosmos.Tests/PatchOperationTests.fs @@ -0,0 +1,101 @@ +namespace Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type PatchOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.Patch_execute_overwrite_updates_item () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "patch" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let patchedName = "item-patched" + let patchedQuantity = 9 + + let! patchResponse = + container.ExecuteOverwriteAsync ( + patch { + id testItem.id + partitionKey testItem.partitionKey + operation (PatchOperation.Replace ("/name", patchedName)) + operation (PatchOperation.Replace ("/quantity", patchedQuantity)) + }, + this.CancellationToken + ) + + match patchResponse.Result with + | PatchResult.Ok _ -> Assert.IsTrue (patchResponse.HttpStatusCode = HttpStatusCode.OK, "Patch should return HTTP 200.") + | result -> Assert.Fail ($"Expected patch success, got {result}.") + + let! readResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let persisted = CosmosAssert.WantOk (readResponse.Result, "Patched item should be readable.") + let isNamePatched = patchedName = persisted.name + let isQuantityPatched = patchedQuantity = persisted.quantity + Assert.IsTrue (isNamePatched, "Patch should persist patched name.") + Assert.IsTrue (isQuantityPatched, "Patch should persist patched quantity.") + } + + [] + member this.PatchAndRead_execute_overwrite_returns_updated_item () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "patch-and-read" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let patchedName = "item-patched-and-read" + let patchedQuantity = 11 + + let! patchResponse = + container.ExecuteOverwriteAsync ( + patchAndRead { + id testItem.id + partitionKey testItem.partitionKey + operation (PatchOperation.Replace ("/name", patchedName)) + operation (PatchOperation.Replace ("/quantity", patchedQuantity)) + }, + this.CancellationToken + ) + + match patchResponse.Result with + | PatchResult.Ok patched -> + let isNamePatched = patchedName = patched.name + let isQuantityPatched = patchedQuantity = patched.quantity + Assert.IsTrue (isNamePatched, "PatchAndRead should return patched name.") + Assert.IsTrue (isQuantityPatched, "PatchAndRead should return patched quantity.") + Assert.IsTrue (patchResponse.HttpStatusCode = HttpStatusCode.OK, "PatchAndRead should return HTTP 200.") + | result -> Assert.Fail ($"Expected patchAndRead success, got {result}.") + } diff --git a/tests/Cosmos.Tests/ReadManyOperationTests.fs b/tests/Cosmos.Tests/ReadManyOperationTests.fs new file mode 100644 index 0000000..8e02113 --- /dev/null +++ b/tests/Cosmos.Tests/ReadManyOperationTests.fs @@ -0,0 +1,58 @@ +namespace Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type ReadManyOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.ReadMany_execute_returns_matching_items () : Task = task { + let! container = this.GetContainer () + let firstItem = this.NewItem "readmany-1" + let secondItem = this.NewItem "readmany-2" + + let! firstCreateResponse = + container.ExecuteAsync ( + create { + item firstItem + partitionKey firstItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (firstCreateResponse.Result, "First seed create should succeed.") + + let! secondCreateResponse = + container.ExecuteAsync ( + create { + item secondItem + partitionKey secondItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (secondCreateResponse.Result, "Second seed create should succeed.") + + let! readManyResponse = + container.ExecuteAsync ( + readMany { + item firstItem.id firstItem.partitionKey + item secondItem.id secondItem.partitionKey + }, + this.CancellationToken + ) + + match readManyResponse.Result with + | ReadManyResult.Ok (feed : FeedResponse) -> + let returnedIds = feed |> Seq.map _.id |> Set.ofSeq + Assert.IsTrue (feed.Count = 2, "ReadMany should return requested number of items.") + Assert.IsTrue (returnedIds.Contains firstItem.id, "ReadMany should include first item.") + Assert.IsTrue (returnedIds.Contains secondItem.id, "ReadMany should include second item.") + Assert.IsTrue (readManyResponse.HttpStatusCode = HttpStatusCode.OK, "ReadMany should return HTTP 200.") + | result -> Assert.Fail ($"Expected read many success, got {result}.") + } diff --git a/tests/Cosmos.Tests/ReadOperationTests.fs b/tests/Cosmos.Tests/ReadOperationTests.fs new file mode 100644 index 0000000..233f39b --- /dev/null +++ b/tests/Cosmos.Tests/ReadOperationTests.fs @@ -0,0 +1,53 @@ +namespace Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type ReadOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.Read_execute_returns_existing_and_not_found_states () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "read" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let! foundResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let found = + CosmosAssert.WantOk (foundResponse.Result, "Read should return ReadResult.Ok for existing item.") + Assert.IsTrue (testItem.id = found.id, "Read should return created item.") + Assert.IsTrue (foundResponse.HttpStatusCode = HttpStatusCode.OK, "Read should return HTTP 200 for existing item.") + + let! missingResponse = + container.ExecuteAsync ( + read { + id $"{testItem.id}-missing" + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsNotFound (missingResponse.Result, "Read should return ReadResult.NotFound for missing item.") + Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read missing should return HTTP 404.") + } diff --git a/tests/Cosmos.Tests/ReplaceOperationTests.fs b/tests/Cosmos.Tests/ReplaceOperationTests.fs new file mode 100644 index 0000000..5684f5b --- /dev/null +++ b/tests/Cosmos.Tests/ReplaceOperationTests.fs @@ -0,0 +1,90 @@ +namespace Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type ReplaceOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.Replace_execute_overwrite_replaces_existing_item () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "replace" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let replacement = { testItem with name = "item-replaced"; quantity = 3 } + + let! replaceResponse = + container.ExecuteOverwriteAsync ( + replace { + id replacement.id + item replacement + partitionKey replacement.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (replaceResponse.Result, "Replace should return ReplaceResult.Ok.") + Assert.IsTrue (replaceResponse.HttpStatusCode = HttpStatusCode.OK, "Replace should return HTTP 200.") + + let! readResponse = + container.ExecuteAsync ( + read { + id replacement.id + partitionKey replacement.partitionKey + }, + this.CancellationToken + ) + + let persisted = CosmosAssert.WantOk (readResponse.Result, "Replaced item should be readable.") + Assert.IsTrue (replacement.name = persisted.name, "Replace should persist replacement name.") + Assert.IsTrue (replacement.quantity = persisted.quantity, "Replace should persist replacement quantity.") + } + + [] + member this.ReplaceAndRead_execute_overwrite_returns_replaced_item () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "replace-and-read" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let replacement = { testItem with name = "item-replaced-and-read"; quantity = 6 } + + let! replaceResponse = + container.ExecuteOverwriteAsync ( + replaceAndRead { + id replacement.id + item replacement + partitionKey replacement.partitionKey + }, + this.CancellationToken + ) + + let replaced = + CosmosAssert.WantOk (replaceResponse.Result, "ReplaceAndRead should return ReplaceResult.Ok.") + Assert.IsTrue (replacement.name = replaced.name, "ReplaceAndRead should return replacement name.") + Assert.IsTrue (replacement.quantity = replaced.quantity, "ReplaceAndRead should return replacement quantity.") + Assert.IsTrue (replaceResponse.HttpStatusCode = HttpStatusCode.OK, "ReplaceAndRead should return HTTP 200.") + } diff --git a/tests/Cosmos.Tests/Tests.fs b/tests/Cosmos.Tests/Tests.fs deleted file mode 100644 index 54689ef..0000000 --- a/tests/Cosmos.Tests/Tests.fs +++ /dev/null @@ -1,298 +0,0 @@ -namespace Tests.Integration - -open System -open System.Net -open System.Threading.Tasks -open FSharp.Azure.Cosmos -open Microsoft.Azure.Cosmos -open Microsoft.VisualStudio.TestTools.UnitTesting - -type private TestItem = { id : string; partitionKey : string; name : string; quantity : int } - -[] -type OperationIntegrationTests () = - inherit IntegrationTestBase () - - let containerId = "operation-tests" - - member private this.GetContainerAsync () : Task = task { - let database = - match this.Application.Database with - | ValueSome database -> database - | ValueNone -> invalidOp "Database is not initialized." - - let! containerResponse = - database.CreateContainerIfNotExistsAsync ( - ContainerProperties (containerId, "/partitionKey"), - cancellationToken = this.CancellationToken - ) - - return containerResponse.Container - } - - member private this.NewItem (suffix : string) : TestItem = { - id = $"{this.TestContext.TestName}-{suffix}" - partitionKey = "integration" - name = $"item-{suffix}" - quantity = 1 - } - - [] - member this.Create_execute_returns_created_resource () : Task = task { - let! container = this.GetContainerAsync () - let testItem = this.NewItem "create" - - let! response = - container.ExecuteAsync ( - createAndRead { - item testItem - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - - let created = CosmosAssert.WantOk (response.Result, "Create should return CreateResult.Ok.") - Assert.IsTrue (testItem.id = created.id, "Create should persist the item id.") - Assert.IsTrue (testItem.partitionKey = created.partitionKey, "Create should persist the item partition key.") - Assert.IsFalse (String.IsNullOrWhiteSpace response.ActivityId, "Create should return a valid activity id.") - Assert.IsTrue (response.RequestCharge > 0.0, "Create should report positive request charge.") - } - - [] - member this.Read_execute_returns_existing_and_not_found_states () : Task = task { - let! container = this.GetContainerAsync () - let testItem = this.NewItem "read" - - let! createdResponse = - container.ExecuteAsync ( - create { - item testItem - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - CosmosAssert.IsOk (createdResponse.Result, "Seed create should succeed.") - - let! foundResponse = - container.ExecuteAsync ( - read { - id testItem.id - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - - let found = - CosmosAssert.WantOk (foundResponse.Result, "Read should return ReadResult.Ok for existing item.") - Assert.IsTrue (testItem.id = found.id, "Read should return the item that was created.") - Assert.IsTrue (foundResponse.HttpStatusCode = HttpStatusCode.OK, "Read success should return HTTP 200.") - - let missingId = $"{testItem.id}-missing" - - let! missingResponse = - container.ExecuteAsync ( - read { - id missingId - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - - CosmosAssert.IsNotFound (missingResponse.Result, "Read of missing item should return ReadResult.NotFound.") - Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read of missing item should return HTTP 404.") - } - - [] - member this.Upsert_execute_overwrite_creates_then_updates_item () : Task = task { - let! container = this.GetContainerAsync () - let testItem = this.NewItem "upsert" - - let! createdResponse = - container.ExecuteOverwriteAsync ( - upsertAndRead { - item testItem - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - - match createdResponse.Result with - | UpsertResult.Ok created -> - Assert.IsTrue (testItem.name = created.name, "Upsert create should store initial payload.") - Assert.IsTrue (createdResponse.HttpStatusCode = HttpStatusCode.Created, "Initial upsert should create item.") - | result -> Assert.Fail ($"Expected upsert create success but received {result}.") - - let updated = { testItem with name = "item-upsert-updated"; quantity = 5 } - - let! updatedResponse = - container.ExecuteOverwriteAsync ( - upsertAndRead { - item updated - partitionKey updated.partitionKey - }, - this.CancellationToken - ) - - match updatedResponse.Result with - | UpsertResult.Ok upserted -> - Assert.IsTrue (updated.name = upserted.name, "Upsert update should persist new payload.") - Assert.IsTrue (updated.quantity = upserted.quantity, "Upsert update should persist new quantity.") - Assert.IsTrue (updatedResponse.HttpStatusCode = HttpStatusCode.OK, "Second upsert should replace existing item.") - | result -> Assert.Fail ($"Expected upsert update success but received {result}.") - } - - [] - member this.Replace_execute_overwrite_replaces_existing_item () : Task = task { - let! container = this.GetContainerAsync () - let testItem = this.NewItem "replace" - - let! createdResponse = - container.ExecuteAsync ( - create { - item testItem - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - CosmosAssert.IsOk (createdResponse.Result, "Seed create should succeed.") - - let replacement = { testItem with name = "item-replaced"; quantity = 3 } - - let! replaceResponse = - container.ExecuteOverwriteAsync ( - replaceAndRead { - id replacement.id - item replacement - partitionKey replacement.partitionKey - }, - this.CancellationToken - ) - - let replaced = CosmosAssert.WantOk (replaceResponse.Result, "Replace should return ReplaceResult.Ok.") - Assert.IsTrue (replacement.name = replaced.name, "Replace should persist replacement name.") - Assert.IsTrue (replacement.quantity = replaced.quantity, "Replace should persist replacement quantity.") - Assert.IsTrue (replaceResponse.HttpStatusCode = HttpStatusCode.OK, "Replace should return HTTP 200.") - } - - [] - member this.Patch_execute_overwrite_updates_targeted_field () : Task = task { - let! container = this.GetContainerAsync () - let testItem = this.NewItem "patch" - let patchedName = "item-patched" - let patchedQuantity = 9 - - let! createdResponse = - container.ExecuteAsync ( - create { - item testItem - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - CosmosAssert.IsOk (createdResponse.Result, "Seed create should succeed.") - - let! patchResponse = - container.ExecuteOverwriteAsync ( - patchAndRead { - id testItem.id - partitionKey testItem.partitionKey - operation (Microsoft.Azure.Cosmos.PatchOperation.Replace ("/name", patchedName)) - operation (Microsoft.Azure.Cosmos.PatchOperation.Replace ("/quantity", patchedQuantity)) - }, - this.CancellationToken - ) - - match patchResponse.Result with - | PatchResult.Ok patched -> - let isNamePatched = patchedName = patched.name - let isQuantityPatched = patchedQuantity = patched.quantity - Assert.IsTrue (isNamePatched, "Patch should update the name field.") - Assert.IsTrue (isQuantityPatched, "Patch should update the quantity field.") - Assert.IsTrue (patchResponse.HttpStatusCode = HttpStatusCode.OK, "Patch should return HTTP 200.") - | result -> Assert.Fail ($"Expected patch success but received {result}.") - } - - [] - member this.Delete_execute_removes_item_and_subsequent_read_is_not_found () : Task = task { - let! container = this.GetContainerAsync () - let testItem = this.NewItem "delete" - - let! createdResponse = - container.ExecuteAsync ( - create { - item testItem - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - CosmosAssert.IsOk (createdResponse.Result, "Seed create should succeed.") - - let! deleteResponse = - container.ExecuteAsync ( - delete { - id testItem.id - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - - CosmosAssert.IsOk (deleteResponse.Result, "Delete should return DeleteResult.Ok.") - Assert.IsTrue (deleteResponse.HttpStatusCode = HttpStatusCode.NoContent, "Delete should return HTTP 204.") - - let! missingResponse = - container.ExecuteAsync ( - read { - id testItem.id - partitionKey testItem.partitionKey - }, - this.CancellationToken - ) - - CosmosAssert.IsNotFound (missingResponse.Result, "Read after delete should return ReadResult.NotFound.") - Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read after delete should return HTTP 404.") - } - - [] - member this.ReadMany_execute_returns_matching_items () : Task = task { - let! container = this.GetContainerAsync () - let firstItem = this.NewItem "readmany-1" - let secondItem = this.NewItem "readmany-2" - - let! firstCreatedResponse = - container.ExecuteAsync ( - create { - item firstItem - partitionKey firstItem.partitionKey - }, - this.CancellationToken - ) - CosmosAssert.IsOk (firstCreatedResponse.Result, "First seed create should succeed.") - - let! secondCreatedResponse = - container.ExecuteAsync ( - create { - item secondItem - partitionKey secondItem.partitionKey - }, - this.CancellationToken - ) - CosmosAssert.IsOk (secondCreatedResponse.Result, "Second seed create should succeed.") - - let! readManyResponse = - container.ExecuteAsync ( - readMany { - item firstItem.id firstItem.partitionKey - item secondItem.id secondItem.partitionKey - }, - this.CancellationToken - ) - - match readManyResponse.Result with - | ReadManyResult.Ok (feed : FeedResponse) -> - let returnedIds = feed |> Seq.map _.id |> Set.ofSeq - Assert.IsTrue (feed.Count = 2, "ReadMany should return the requested number of existing items.") - Assert.IsTrue (returnedIds.Contains firstItem.id, "ReadMany should include first requested item.") - Assert.IsTrue (returnedIds.Contains secondItem.id, "ReadMany should include second requested item.") - Assert.IsTrue (readManyResponse.HttpStatusCode = HttpStatusCode.OK, "ReadMany should return HTTP 200.") - | result -> Assert.Fail ($"Expected read many success but received {result}.") - } diff --git a/tests/Cosmos.Tests/UpsertOperationTests.fs b/tests/Cosmos.Tests/UpsertOperationTests.fs new file mode 100644 index 0000000..2557435 --- /dev/null +++ b/tests/Cosmos.Tests/UpsertOperationTests.fs @@ -0,0 +1,101 @@ +namespace Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type UpsertOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.Upsert_execute_overwrite_creates_then_updates_item () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "upsert" + + let! createResult = + container.ExecuteOverwriteAsync ( + upsert { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match createResult.Result with + | UpsertResult.Ok _ -> + Assert.IsTrue (createResult.HttpStatusCode = HttpStatusCode.Created, "First upsert should create item (HTTP 201).") + | result -> Assert.Fail ($"Expected first upsert success, got {result}.") + + let updated = { testItem with name = "item-upsert-updated"; quantity = 5 } + + let! updateResult = + container.ExecuteOverwriteAsync ( + upsert { + item updated + partitionKey updated.partitionKey + }, + this.CancellationToken + ) + + match updateResult.Result with + | UpsertResult.Ok _ -> + Assert.IsTrue (updateResult.HttpStatusCode = HttpStatusCode.OK, "Second upsert should update item (HTTP 200).") + | result -> Assert.Fail ($"Expected second upsert success, got {result}.") + + let! readResponse = + container.ExecuteAsync ( + read { + id updated.id + partitionKey updated.partitionKey + }, + this.CancellationToken + ) + + let persisted = CosmosAssert.WantOk (readResponse.Result, "Updated upsert item should be readable.") + Assert.IsTrue (updated.name = persisted.name, "Upsert should persist updated name.") + Assert.IsTrue (updated.quantity = persisted.quantity, "Upsert should persist updated quantity.") + } + + [] + member this.UpsertAndRead_execute_overwrite_returns_updated_item () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "upsert-and-read" + + let! createdResponse = + container.ExecuteOverwriteAsync ( + upsertAndRead { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match createdResponse.Result with + | UpsertResult.Ok created -> + Assert.IsTrue (testItem.name = created.name, "UpsertAndRead create should return created resource.") + Assert.IsTrue ( + createdResponse.HttpStatusCode = HttpStatusCode.Created, + "UpsertAndRead create should return HTTP 201." + ) + | result -> Assert.Fail ($"Expected upsertAndRead create success, got {result}.") + + let updated = { testItem with name = "item-upsert-and-read-updated"; quantity = 9 } + + let! updatedResponse = + container.ExecuteOverwriteAsync ( + upsertAndRead { + item updated + partitionKey updated.partitionKey + }, + this.CancellationToken + ) + + match updatedResponse.Result with + | UpsertResult.Ok upserted -> + Assert.IsTrue (updated.name = upserted.name, "UpsertAndRead update should return updated name.") + Assert.IsTrue (updated.quantity = upserted.quantity, "UpsertAndRead update should return updated quantity.") + Assert.IsTrue (updatedResponse.HttpStatusCode = HttpStatusCode.OK, "UpsertAndRead update should return HTTP 200.") + | result -> Assert.Fail ($"Expected upsertAndRead update success, got {result}.") + } From 4a47672beb75a3fa322e615b25a279e643be0c69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 01:21:05 +0000 Subject: [PATCH 13/33] Adjust shared operation test fixture visibility Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/727cc9a9-e850-45de-b65f-4e8eb8ff33fc Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/OperationTestInfrastructure.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Cosmos.Tests/OperationTestInfrastructure.fs b/tests/Cosmos.Tests/OperationTestInfrastructure.fs index 9668e05..c3f4e26 100644 --- a/tests/Cosmos.Tests/OperationTestInfrastructure.fs +++ b/tests/Cosmos.Tests/OperationTestInfrastructure.fs @@ -4,7 +4,7 @@ open System open System.Threading.Tasks open Microsoft.Azure.Cosmos -type TestItem = { id : string; partitionKey : string; name : string; quantity : int } +type internal TestItem = { id : string; partitionKey : string; name : string; quantity : int } [] type OperationTestBase () = @@ -30,7 +30,7 @@ type OperationTestBase () = return containerResponse.Container } - member this.NewItem (suffix : string) : TestItem = { + member internal this.NewItem (suffix : string) : TestItem = { id = $"{this.TestContext.TestName}-{suffix}" partitionKey = "integration" name = $"item-{suffix}" From 0bf340cb65c05b3cccd7fae1e9b4e743b7411cc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 01:22:08 +0000 Subject: [PATCH 14/33] Remove class-scope literal from operation test infrastructure Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/727cc9a9-e850-45de-b65f-4e8eb8ff33fc Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/OperationTestInfrastructure.fs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Cosmos.Tests/OperationTestInfrastructure.fs b/tests/Cosmos.Tests/OperationTestInfrastructure.fs index c3f4e26..ba7f91f 100644 --- a/tests/Cosmos.Tests/OperationTestInfrastructure.fs +++ b/tests/Cosmos.Tests/OperationTestInfrastructure.fs @@ -10,7 +10,6 @@ type internal TestItem = { id : string; partitionKey : string; name : string; qu type OperationTestBase () = inherit IntegrationTestBase () - [] let containerId = "operation-tests" member private this.GetDatabase () = From 03795ebd07db48ed67721397c41292838b711997 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 01:30:34 +0000 Subject: [PATCH 15/33] Rename test methods add read extension coverage and builder tests Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/63ac5990-d78f-452f-9f2f-85e08fdbff91 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/BuilderUnitTests.fs | 234 ++++++++++++++++++ .../Cosmos.Tests/CosmosReadExtensionsTests.fs | 68 +++++ tests/Cosmos.Tests/CreateOperationTests.fs | 4 +- tests/Cosmos.Tests/DeleteOperationTests.fs | 2 +- .../FSharp.Azure.Cosmos.Tests.fsproj | 3 + .../Cosmos.Tests/IterationExtensionsTests.fs | 56 +++++ .../OperationTestInfrastructure.fs | 15 ++ tests/Cosmos.Tests/PatchOperationTests.fs | 4 +- tests/Cosmos.Tests/ReadManyOperationTests.fs | 2 +- tests/Cosmos.Tests/ReadOperationTests.fs | 2 +- tests/Cosmos.Tests/ReplaceOperationTests.fs | 63 ++++- tests/Cosmos.Tests/UpsertOperationTests.fs | 65 ++++- 12 files changed, 507 insertions(+), 11 deletions(-) create mode 100644 tests/Cosmos.Tests/BuilderUnitTests.fs create mode 100644 tests/Cosmos.Tests/CosmosReadExtensionsTests.fs create mode 100644 tests/Cosmos.Tests/IterationExtensionsTests.fs diff --git a/tests/Cosmos.Tests/BuilderUnitTests.fs b/tests/Cosmos.Tests/BuilderUnitTests.fs new file mode 100644 index 0000000..913c45c --- /dev/null +++ b/tests/Cosmos.Tests/BuilderUnitTests.fs @@ -0,0 +1,234 @@ +namespace Tests.Unit + +open System +open FSharp.Azure.Cosmos +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +type private BuilderTestItem = { id : string; partitionKey : string; value : int } + +[] +type BuilderUnitTests () = + + [] + member _.``Create builders configure operation and content response mode`` () = + let createItem = { id = "create-id"; partitionKey = "pk"; value = 1 } + + let createOperation = create { + item createItem + partitionKey createItem.partitionKey + sessionToken "create-session" + } + + let createAndReadOperation = createAndRead { + item createItem + partitionKey createItem.partitionKey + sessionToken "create-and-read-session" + } + + Assert.IsTrue (createOperation.PartitionKey |> ValueOption.isSome, "Create builder should set partition key.") + Assert.IsTrue (createOperation.RequestOptions.SessionToken = "create-session", "Create builder should set session token.") + Assert.IsFalse ( + createOperation.RequestOptions.EnableContentResponseOnWrite, + "Create builder should disable content response." + ) + Assert.IsTrue ( + createAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "CreateAndRead builder should enable content response." + ) + + [] + member _.``Read builder configures id partition key and request options`` () = + let operation = read { + id "read-id" + partitionKey "pk" + eTag "etag-value" + sessionToken "read-session" + } + + Assert.IsTrue (operation.Id = "read-id", "Read builder should set id.") + Assert.IsFalse (isNull operation.RequestOptions, "Read builder should initialize request options when needed.") + Assert.IsTrue (operation.RequestOptions.IfNoneMatchEtag = "etag-value", "Read builder should set eTag option.") + Assert.IsTrue (operation.RequestOptions.SessionToken = "read-session", "Read builder should set session token.") + + [] + member _.``ReadMany builder collects item tuples and request options`` () = + let operation = readMany { + item "item-1" "pk" + item "item-2" (PartitionKey "pk") + sessionToken "readmany-session" + } + + Assert.IsTrue (operation.Items.Length = 2, "ReadMany builder should collect all item tuples.") + Assert.IsFalse (isNull operation.RequestOptions, "ReadMany builder should create request options when needed.") + Assert.IsTrue (operation.RequestOptions.SessionToken = "readmany-session", "ReadMany builder should set session token.") + + [] + member _.``Replace builders configure operation and content response mode`` () = + let replaceItem = { id = "replace-id"; partitionKey = "pk"; value = 1 } + + let replaceOperation = replace { + id replaceItem.id + item replaceItem + partitionKey replaceItem.partitionKey + eTag "replace-etag" + } + + let replaceAndReadOperation = replaceAndRead { + id replaceItem.id + item replaceItem + partitionKey replaceItem.partitionKey + } + + Assert.IsTrue (replaceOperation.Id = replaceItem.id, "Replace builder should set id.") + Assert.IsTrue (replaceOperation.RequestOptions.IfMatchEtag = "replace-etag", "Replace builder should set eTag.") + Assert.IsFalse ( + replaceOperation.RequestOptions.EnableContentResponseOnWrite, + "Replace builder should disable content response." + ) + Assert.IsTrue ( + replaceAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "ReplaceAndRead builder should enable content response." + ) + + [] + member _.``Replace concurrently builders configure update function and response mode`` () = + let replaceConcurrentlyOperation = replaceConcurrenly { + id "replace-concurrent-id" + partitionKey "pk" + update (fun item -> async { return Result.Ok { item with value = item.value + 1 } }) + } + + let replaceConcurrentlyAndReadOperation = replaceConcurrenlyAndRead { + id "replace-concurrent-and-read-id" + partitionKey "pk" + update (fun item -> async { return Result.Ok item }) + } + + let updateResult = + replaceConcurrentlyOperation.Update { id = "id"; partitionKey = "pk"; value = 2 } + |> Async.RunSynchronously + + Assert.IsTrue (replaceConcurrentlyOperation.Id = "replace-concurrent-id", "Replace concurrently builder should set id.") + Assert.IsTrue (Result.isOk updateResult, "Replace concurrently builder should set update function.") + Assert.IsFalse ( + replaceConcurrentlyOperation.RequestOptions.EnableContentResponseOnWrite, + "Replace concurrently builder should disable content response." + ) + Assert.IsTrue ( + replaceConcurrentlyAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "Replace concurrently and read builder should enable content response." + ) + + [] + member _.``Upsert builders configure operation and content response mode`` () = + let upsertItem = { id = "upsert-id"; partitionKey = "pk"; value = 1 } + + let upsertOperation = upsert { + item upsertItem + partitionKey upsertItem.partitionKey + eTag "upsert-etag" + } + + let upsertAndReadOperation = upsertAndRead { + item upsertItem + partitionKey upsertItem.partitionKey + } + + Assert.IsTrue (upsertOperation.PartitionKey |> ValueOption.isSome, "Upsert builder should set partition key.") + Assert.IsTrue (upsertOperation.RequestOptions.IfMatchEtag = "upsert-etag", "Upsert builder should set eTag.") + Assert.IsFalse ( + upsertOperation.RequestOptions.EnableContentResponseOnWrite, + "Upsert builder should disable content response." + ) + Assert.IsTrue ( + upsertAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "UpsertAndRead builder should enable content response." + ) + + [] + member _.``Upsert concurrently builders configure updateOrCreate and response mode`` () = + let upsertConcurrentlyOperation = upsertConcurrenly { + id "upsert-concurrent-id" + partitionKey "pk" + updateOrCreate (fun maybeItem -> async { + match maybeItem with + | Some item -> return Result.Ok { item with value = item.value + 1 } + | None -> return Result.Ok { id = "new-id"; partitionKey = "pk"; value = 1 } + }) + } + + let upsertConcurrentlyAndReadOperation = upsertConcurrenlyAndRead { + id "upsert-concurrent-and-read-id" + partitionKey "pk" + updateOrCreate (fun _ -> async { return Error "custom-error" }) + } + + let updateResult = + upsertConcurrentlyOperation.UpdateOrCreate None + |> Async.RunSynchronously + + Assert.IsTrue (upsertConcurrentlyOperation.Id = "upsert-concurrent-id", "Upsert concurrently builder should set id.") + Assert.IsTrue (Result.isOk updateResult, "Upsert concurrently builder should set updateOrCreate function.") + Assert.IsFalse ( + upsertConcurrentlyOperation.RequestOptions.EnableContentResponseOnWrite, + "Upsert concurrently builder should disable content response." + ) + Assert.IsTrue ( + upsertConcurrentlyAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "Upsert concurrently and read builder should enable content response." + ) + + [] + member _.``Patch builders configure operations and content response mode`` () = + let patchOperation = patch { + id "patch-id" + partitionKey "pk" + operation (PatchOperation.Replace ("/value", 2)) + filterPredicate "FROM c WHERE c.partitionKey = 'pk'" + } + + let patchAndReadOperation = patchAndRead { + id "patch-and-read-id" + partitionKey "pk" + operation (PatchOperation.Replace ("/value", 5)) + } + + Assert.IsTrue (patchOperation.Id = "patch-id", "Patch builder should set id.") + Assert.IsTrue (patchOperation.Operations.Length = 1, "Patch builder should collect operations.") + Assert.IsTrue ( + patchOperation.RequestOptions.FilterPredicate = "FROM c WHERE c.partitionKey = 'pk'", + "Patch builder should set filter predicate." + ) + Assert.IsFalse ( + patchOperation.RequestOptions.EnableContentResponseOnWrite, + "Patch builder should disable content response." + ) + Assert.IsTrue ( + patchAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "PatchAndRead builder should enable content response." + ) + + [] + member _.``Delete builder configures id partition key and request options`` () = + let operation = delete { + id "delete-id" + partitionKey "pk" + eTag "delete-etag" + sessionToken "delete-session" + } + + Assert.IsTrue (operation.Id = "delete-id", "Delete builder should set id.") + Assert.IsTrue (operation.RequestOptions |> ValueOption.isSome, "Delete builder should initialize request options.") + + let options = operation.RequestOptions |> ValueOption.get + Assert.IsTrue (options.IfNoneMatchEtag = "delete-etag", "Delete builder should set eTag.") + Assert.IsTrue (options.SessionToken = "delete-session", "Delete builder should set session token.") + + [] + member _.``Unique key builders configure key and policy paths`` () = + let uniqueKeyDefinition = uniqueKey { paths [ "/tenantId"; "/email" ] } + let policy = uniqueKeyPolicy { key uniqueKeyDefinition } + + Assert.IsTrue (uniqueKeyDefinition.Paths.Count = 2, "UniqueKey builder should add all paths.") + Assert.IsTrue (policy.UniqueKeys.Count = 1, "UniqueKeyPolicy builder should add unique key.") diff --git a/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs b/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs new file mode 100644 index 0000000..4dd36b5 --- /dev/null +++ b/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs @@ -0,0 +1,68 @@ +namespace Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type CosmosReadExtensionsIntegrationTests () = + inherit OperationTestBase () + + [] + member this.``CountAsync and LongCountAsync return seeded item counts`` () : Task = task { + let! container = this.GetContainer () + let seededItems = [ this.NewItem "count-1"; this.NewItem "count-2"; this.NewItem "count-3" ] + do! this.SeedItemsAsync (container, seededItems) + + let! countByPartition = container.CountAsync ("integration", cancellationToken = this.CancellationToken) + let! countByQuery = container.CountAsync (QueryRequestOptions (), cancellationToken = this.CancellationToken) + let! longCountByPartition = + container.LongCountAsync (PartitionKey "integration", cancellationToken = this.CancellationToken) + + Assert.IsTrue ((countByPartition = 3), "CountAsync by partition should return seeded item count.") + Assert.IsTrue ((countByQuery = 3), "CountAsync by query options should return seeded item count.") + Assert.IsTrue ((longCountByPartition = 3L), "LongCountAsync should return seeded item count.") + } + + [] + member this.``ExistsAsync and IsNotDeletedAsync return expected values`` () : Task = task { + let! container = this.GetContainer () + let firstItem = this.NewItem "exists-1" + let secondItem = this.NewItem "exists-2" + do! this.SeedItemsAsync (container, [ firstItem; secondItem ]) + + let! existsWithPartition = + container.ExistsAsync (firstItem.id, PartitionKey firstItem.partitionKey, this.CancellationToken) + + let! existsWithoutPartition = container.ExistsAsync (firstItem.id, cancellationToken = this.CancellationToken) + + let! missingExists = container.ExistsAsync ($"{firstItem.id}-missing", cancellationToken = this.CancellationToken) + + Assert.IsTrue (existsWithPartition, "ExistsAsync with partition key should return true for existing item.") + Assert.IsTrue (existsWithoutPartition, "ExistsAsync without partition key should return true for existing item.") + Assert.IsFalse (missingExists, "ExistsAsync should return false for missing item.") + + let! notDeletedBeforePatch = container.IsNotDeletedAsync "deletedAt" secondItem.id + + Assert.IsTrue (notDeletedBeforePatch, "IsNotDeletedAsync should return true before deleted marker is set.") + + let! patchResponse = + container.ExecuteOverwriteAsync ( + patch { + id secondItem.id + partitionKey secondItem.partitionKey + operation (PatchOperation.Set ("/deletedAt", "2026-05-24T00:00:00Z")) + }, + this.CancellationToken + ) + + match patchResponse.Result with + | PatchResult.Ok _ -> Assert.IsTrue (patchResponse.HttpStatusCode = HttpStatusCode.OK, "Patch should return HTTP 200.") + | result -> Assert.Fail ($"Expected patch success, got {result}.") + + let! notDeletedAfterPatch = container.IsNotDeletedAsync "deletedAt" secondItem.id + + Assert.IsFalse (notDeletedAfterPatch, "IsNotDeletedAsync should return false after deleted marker is set.") + } diff --git a/tests/Cosmos.Tests/CreateOperationTests.fs b/tests/Cosmos.Tests/CreateOperationTests.fs index df76a1a..3062d8d 100644 --- a/tests/Cosmos.Tests/CreateOperationTests.fs +++ b/tests/Cosmos.Tests/CreateOperationTests.fs @@ -10,7 +10,7 @@ type CreateOperationIntegrationTests () = inherit OperationTestBase () [] - member this.Create_execute_persists_item () : Task = task { + member this.``Create execute persists item`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "create" @@ -41,7 +41,7 @@ type CreateOperationIntegrationTests () = } [] - member this.CreateAndRead_execute_returns_created_resource () : Task = task { + member this.``CreateAndRead execute returns created resource`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "create-and-read" diff --git a/tests/Cosmos.Tests/DeleteOperationTests.fs b/tests/Cosmos.Tests/DeleteOperationTests.fs index c633b81..0d4ae7b 100644 --- a/tests/Cosmos.Tests/DeleteOperationTests.fs +++ b/tests/Cosmos.Tests/DeleteOperationTests.fs @@ -10,7 +10,7 @@ type DeleteOperationIntegrationTests () = inherit OperationTestBase () [] - member this.Delete_execute_removes_item_and_subsequent_read_is_not_found () : Task = task { + member this.``Delete execute removes item and subsequent read is not found`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "delete" diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index cc8cae0..768bf45 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -29,6 +29,9 @@ + + + diff --git a/tests/Cosmos.Tests/IterationExtensionsTests.fs b/tests/Cosmos.Tests/IterationExtensionsTests.fs new file mode 100644 index 0000000..009b57a --- /dev/null +++ b/tests/Cosmos.Tests/IterationExtensionsTests.fs @@ -0,0 +1,56 @@ +namespace Tests.Integration + +open System.Threading.Tasks +open FSharp.Control +open Microsoft.Azure.Cosmos +open Microsoft.Azure.Cosmos.Linq +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type IterationExtensionsIntegrationTests () = + inherit OperationTestBase () + + [] + member this.``FeedIterator AsAsyncEnumerable iterates seeded items`` () : Task = task { + let! container = this.GetContainer () + let firstItem = this.NewItem "iterator-1" + let secondItem = this.NewItem "iterator-2" + do! this.SeedItemsAsync (container, [ firstItem; secondItem ]) + + let query = + QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @partitionKey").WithParameter ("@partitionKey", "integration") + + let iterator = container.GetItemQueryIterator (query) + let expectedIds = set [ firstItem.id; secondItem.id ] + let! iteratedItems = + iterator.AsAsyncEnumerable (this.CancellationToken) + |> TaskSeq.toListAsync + let foundCount = + iteratedItems + |> List.filter (fun item -> expectedIds.Contains item.id) + |> List.length + Assert.IsTrue ((foundCount = 2), "FeedIterator.AsAsyncEnumerable should iterate seeded items.") + } + + [] + member this.``IQueryable AsAsyncEnumerable iterates seeded items`` () : Task = task { + let! container = this.GetContainer () + let firstItem = this.NewItem "queryable-1" + let secondItem = this.NewItem "queryable-2" + do! this.SeedItemsAsync (container, [ firstItem; secondItem ]) + + let queryable = + container.GetItemLinqQueryable ( + requestOptions = QueryRequestOptions (PartitionKey = PartitionKey "integration") + ) + + let expectedIds = set [ firstItem.id; secondItem.id ] + let! iteratedItems = + queryable.AsAsyncEnumerable (this.CancellationToken) + |> TaskSeq.toListAsync + let foundCount = + iteratedItems + |> List.filter (fun item -> expectedIds.Contains item.id) + |> List.length + Assert.IsTrue ((foundCount = 2), "IQueryable.AsAsyncEnumerable should iterate seeded items.") + } diff --git a/tests/Cosmos.Tests/OperationTestInfrastructure.fs b/tests/Cosmos.Tests/OperationTestInfrastructure.fs index ba7f91f..05c0b56 100644 --- a/tests/Cosmos.Tests/OperationTestInfrastructure.fs +++ b/tests/Cosmos.Tests/OperationTestInfrastructure.fs @@ -2,6 +2,7 @@ namespace Tests.Integration open System open System.Threading.Tasks +open FSharp.Azure.Cosmos open Microsoft.Azure.Cosmos type internal TestItem = { id : string; partitionKey : string; name : string; quantity : int } @@ -35,3 +36,17 @@ type OperationTestBase () = name = $"item-{suffix}" quantity = 1 } + + member internal this.SeedItemsAsync (container : Container, items : TestItem seq) : Task = task { + for seedItem in items do + let! createResponse = + container.ExecuteAsync ( + create { + item seedItem + partitionKey seedItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, $"Seed create should succeed for item '{seedItem.id}'.") + } diff --git a/tests/Cosmos.Tests/PatchOperationTests.fs b/tests/Cosmos.Tests/PatchOperationTests.fs index d57ee12..9676d57 100644 --- a/tests/Cosmos.Tests/PatchOperationTests.fs +++ b/tests/Cosmos.Tests/PatchOperationTests.fs @@ -11,7 +11,7 @@ type PatchOperationIntegrationTests () = inherit OperationTestBase () [] - member this.Patch_execute_overwrite_updates_item () : Task = task { + member this.``Patch execute overwrite updates item`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "patch" @@ -61,7 +61,7 @@ type PatchOperationIntegrationTests () = } [] - member this.PatchAndRead_execute_overwrite_returns_updated_item () : Task = task { + member this.``PatchAndRead execute overwrite returns updated item`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "patch-and-read" diff --git a/tests/Cosmos.Tests/ReadManyOperationTests.fs b/tests/Cosmos.Tests/ReadManyOperationTests.fs index 8e02113..615798e 100644 --- a/tests/Cosmos.Tests/ReadManyOperationTests.fs +++ b/tests/Cosmos.Tests/ReadManyOperationTests.fs @@ -11,7 +11,7 @@ type ReadManyOperationIntegrationTests () = inherit OperationTestBase () [] - member this.ReadMany_execute_returns_matching_items () : Task = task { + member this.``ReadMany execute returns matching items`` () : Task = task { let! container = this.GetContainer () let firstItem = this.NewItem "readmany-1" let secondItem = this.NewItem "readmany-2" diff --git a/tests/Cosmos.Tests/ReadOperationTests.fs b/tests/Cosmos.Tests/ReadOperationTests.fs index 233f39b..74a0808 100644 --- a/tests/Cosmos.Tests/ReadOperationTests.fs +++ b/tests/Cosmos.Tests/ReadOperationTests.fs @@ -10,7 +10,7 @@ type ReadOperationIntegrationTests () = inherit OperationTestBase () [] - member this.Read_execute_returns_existing_and_not_found_states () : Task = task { + member this.``Read execute returns existing and not found states`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "read" diff --git a/tests/Cosmos.Tests/ReplaceOperationTests.fs b/tests/Cosmos.Tests/ReplaceOperationTests.fs index 5684f5b..f915813 100644 --- a/tests/Cosmos.Tests/ReplaceOperationTests.fs +++ b/tests/Cosmos.Tests/ReplaceOperationTests.fs @@ -10,7 +10,7 @@ type ReplaceOperationIntegrationTests () = inherit OperationTestBase () [] - member this.Replace_execute_overwrite_replaces_existing_item () : Task = task { + member this.``Replace execute overwrite replaces existing item`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "replace" @@ -55,7 +55,7 @@ type ReplaceOperationIntegrationTests () = } [] - member this.ReplaceAndRead_execute_overwrite_returns_replaced_item () : Task = task { + member this.``ReplaceAndRead execute overwrite returns replaced item`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "replace-and-read" @@ -88,3 +88,62 @@ type ReplaceOperationIntegrationTests () = Assert.IsTrue (replacement.quantity = replaced.quantity, "ReplaceAndRead should return replacement quantity.") Assert.IsTrue (replaceResponse.HttpStatusCode = HttpStatusCode.OK, "ReplaceAndRead should return HTTP 200.") } + + [] + member this.``Replace concurrently retries and applies update`` () : Task = task { + let! container = this.GetContainer () + let original = this.NewItem "replace-concurrent" + + let! createResponse = + container.ExecuteAsync ( + create { + item original + partitionKey original.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let mutable conflictInjected = false + + let operation = replaceConcurrenly { + id original.id + partitionKey original.partitionKey + update (fun current -> async { + if not conflictInjected then + conflictInjected <- true + + let competingUpdate = { current with name = "competing-update" } + + let! _ = + container.ExecuteOverwriteAsync ( + replace { + id competingUpdate.id + item competingUpdate + partitionKey competingUpdate.partitionKey + }, + this.CancellationToken + ) + |> Async.AwaitTask + + () + + return + Result.Ok { + current with + name = "replace-concurrent-updated" + quantity = current.quantity + 10 + } + }) + } + + let! concurrentResponse = container.ExecuteConcurrentlyAsync (operation, 3, this.CancellationToken) + + match concurrentResponse.Result with + | ReplaceConcurrentResult.Ok updated -> + Assert.IsTrue (conflictInjected, "Replace concurrently test should inject a conflicting update at least once.") + Assert.IsTrue (updated.name = "replace-concurrent-updated", "Replace concurrently should persist updated name.") + Assert.IsTrue (updated.quantity = original.quantity + 10, "Replace concurrently should persist updated quantity.") + | result -> Assert.Fail ($"Expected replace concurrently success after retry, got {result}.") + } diff --git a/tests/Cosmos.Tests/UpsertOperationTests.fs b/tests/Cosmos.Tests/UpsertOperationTests.fs index 2557435..f775baa 100644 --- a/tests/Cosmos.Tests/UpsertOperationTests.fs +++ b/tests/Cosmos.Tests/UpsertOperationTests.fs @@ -10,7 +10,7 @@ type UpsertOperationIntegrationTests () = inherit OperationTestBase () [] - member this.Upsert_execute_overwrite_creates_then_updates_item () : Task = task { + member this.``Upsert execute overwrite creates then updates item`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "upsert" @@ -59,7 +59,7 @@ type UpsertOperationIntegrationTests () = } [] - member this.UpsertAndRead_execute_overwrite_returns_updated_item () : Task = task { + member this.``UpsertAndRead execute overwrite returns updated item`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "upsert-and-read" @@ -99,3 +99,64 @@ type UpsertOperationIntegrationTests () = Assert.IsTrue (updatedResponse.HttpStatusCode = HttpStatusCode.OK, "UpsertAndRead update should return HTTP 200.") | result -> Assert.Fail ($"Expected upsertAndRead update success, got {result}.") } + + [] + member this.``Upsert concurrently retries and applies update`` () : Task = task { + let! container = this.GetContainer () + let original = this.NewItem "upsert-concurrent" + + let! createResponse = + container.ExecuteAsync ( + create { + item original + partitionKey original.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let mutable conflictInjected = false + + let operation = upsertConcurrenly { + id original.id + partitionKey original.partitionKey + updateOrCreate (fun maybeCurrent -> async { + match maybeCurrent with + | Some current -> + if not conflictInjected then + conflictInjected <- true + + let competingUpdate = { current with name = "competing-upsert-update" } + + let! _ = + container.ExecuteOverwriteAsync ( + upsert { + item competingUpdate + partitionKey competingUpdate.partitionKey + }, + this.CancellationToken + ) + |> Async.AwaitTask + + () + + return + Result.Ok { + current with + name = "upsert-concurrent-updated" + quantity = current.quantity + 7 + } + | None -> return Result.Error "Expected existing item for concurrent upsert test." + }) + } + + let! concurrentResponse = container.ExecuteConcurrentlyAsync (operation, 3, this.CancellationToken) + + match concurrentResponse.Result with + | UpsertConcurrentResult.Ok updated -> + Assert.IsTrue (conflictInjected, "Upsert concurrently test should inject a conflicting update at least once.") + Assert.IsTrue (updated.name = "upsert-concurrent-updated", "Upsert concurrently should persist updated name.") + Assert.IsTrue (updated.quantity = original.quantity + 7, "Upsert concurrently should persist updated quantity.") + | result -> Assert.Fail ($"Expected upsert concurrently success after retry, got {result}.") + } From 31d54c0c9cf03816f082ba92dc96815c59bf0ed2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 14:32:46 +0000 Subject: [PATCH 16/33] Add Assert extensions and align test namespaces Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/6f71a1f0-316c-4294-88a6-23f8995238fc Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/Assert.fs | 72 +++++++++++++++++++ tests/Cosmos.Tests/BuilderUnitTests.fs | 15 ++-- tests/Cosmos.Tests/CosmosAssert.fs | 2 +- .../Cosmos.Tests/CosmosReadExtensionsTests.fs | 2 +- tests/Cosmos.Tests/CreateOperationTests.fs | 2 +- tests/Cosmos.Tests/DeleteOperationTests.fs | 2 +- .../FSharp.Azure.Cosmos.Tests.fsproj | 1 + .../Cosmos.Tests/IntegrationInfrastructure.fs | 2 +- tests/Cosmos.Tests/IntegrationTestPlan.fs | 2 +- .../Cosmos.Tests/IterationExtensionsTests.fs | 2 +- .../OperationTestInfrastructure.fs | 2 +- tests/Cosmos.Tests/PatchOperationTests.fs | 2 +- tests/Cosmos.Tests/ReadManyOperationTests.fs | 2 +- tests/Cosmos.Tests/ReadOperationTests.fs | 2 +- tests/Cosmos.Tests/ReplaceOperationTests.fs | 2 +- tests/Cosmos.Tests/UpsertOperationTests.fs | 2 +- 16 files changed, 93 insertions(+), 21 deletions(-) create mode 100644 tests/Cosmos.Tests/Assert.fs diff --git a/tests/Cosmos.Tests/Assert.fs b/tests/Cosmos.Tests/Assert.fs new file mode 100644 index 0000000..c73402e --- /dev/null +++ b/tests/Cosmos.Tests/Assert.fs @@ -0,0 +1,72 @@ +namespace FSharp.Azure.Cosmos.Tests + +open System.Runtime.InteropServices +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +module AssertExtensions = + + type Assert with + + static member WantSome (value, [] message) = + match value with + | Some some -> some + | None -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsSome (value, [] message) = Assert.WantSome (value, message) |> ignore + + static member IsNone (value, [] message) = + match value with + | Some _ -> Assert.Fail (message) + | None -> () + + static member WantValueSome (value, [] message) = + match value with + | ValueSome some -> some + | ValueNone -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsValueSome (value, [] message) = Assert.WantValueSome (value, message) |> ignore + + static member IsValueNone (value, [] message) = + match value with + | ValueSome _ -> Assert.Fail (message) + | ValueNone -> () + + static member WantOk (value, [] message : string | null) = + match value with + | Ok ok -> ok + | Error error -> + match message with + | null -> Assert.Fail (string error) + | message -> Assert.Fail ($"'{message}': {error}") + Unchecked.defaultof<_> + + static member IsOk (value, [] message) = Assert.WantOk (value, message) |> ignore + + static member WantError (value, [] message : string | null) = + match value with + | Error error -> error + | Ok value -> + match message with + | null -> Assert.Fail (string value) + | message -> Assert.Fail ($"'{message}': {value}") + Unchecked.defaultof<_> + + static member IsError (value, [] message) = Assert.WantError (value, message) |> ignore + + static member inline IsDefaultOf< ^T> (value : ^T, [] message : string) = + Assert.AreEqual (box value, box Unchecked.defaultof< ^T>, message) + + static member inline OkEquals< ^R, 'E> (expected : ^R, actual : Result< ^R, 'E >, [] message) = + Assert.AreEqual (box expected, box (Assert.WantOk (actual, message)), message) + + static member inline ErrorEquals<'R, ^E> (expected : ^E, actual : Result<'R, ^E>, [] message) = + Assert.AreEqual (box expected, box (Assert.WantError (actual, message)), message) + + static member FailWithData<'T> ([] message) = + Assert.Fail (message) + Unchecked.defaultof<'T> diff --git a/tests/Cosmos.Tests/BuilderUnitTests.fs b/tests/Cosmos.Tests/BuilderUnitTests.fs index 913c45c..3eb5478 100644 --- a/tests/Cosmos.Tests/BuilderUnitTests.fs +++ b/tests/Cosmos.Tests/BuilderUnitTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Unit +namespace FSharp.Azure.Cosmos.Tests open System open FSharp.Azure.Cosmos @@ -26,7 +26,7 @@ type BuilderUnitTests () = sessionToken "create-and-read-session" } - Assert.IsTrue (createOperation.PartitionKey |> ValueOption.isSome, "Create builder should set partition key.") + Assert.IsValueSome (createOperation.PartitionKey, "Create builder should set partition key.") Assert.IsTrue (createOperation.RequestOptions.SessionToken = "create-session", "Create builder should set session token.") Assert.IsFalse ( createOperation.RequestOptions.EnableContentResponseOnWrite, @@ -110,7 +110,7 @@ type BuilderUnitTests () = |> Async.RunSynchronously Assert.IsTrue (replaceConcurrentlyOperation.Id = "replace-concurrent-id", "Replace concurrently builder should set id.") - Assert.IsTrue (Result.isOk updateResult, "Replace concurrently builder should set update function.") + Assert.IsOk (updateResult, "Replace concurrently builder should set update function.") Assert.IsFalse ( replaceConcurrentlyOperation.RequestOptions.EnableContentResponseOnWrite, "Replace concurrently builder should disable content response." @@ -135,7 +135,7 @@ type BuilderUnitTests () = partitionKey upsertItem.partitionKey } - Assert.IsTrue (upsertOperation.PartitionKey |> ValueOption.isSome, "Upsert builder should set partition key.") + Assert.IsValueSome (upsertOperation.PartitionKey, "Upsert builder should set partition key.") Assert.IsTrue (upsertOperation.RequestOptions.IfMatchEtag = "upsert-etag", "Upsert builder should set eTag.") Assert.IsFalse ( upsertOperation.RequestOptions.EnableContentResponseOnWrite, @@ -169,7 +169,7 @@ type BuilderUnitTests () = |> Async.RunSynchronously Assert.IsTrue (upsertConcurrentlyOperation.Id = "upsert-concurrent-id", "Upsert concurrently builder should set id.") - Assert.IsTrue (Result.isOk updateResult, "Upsert concurrently builder should set updateOrCreate function.") + Assert.IsOk (updateResult, "Upsert concurrently builder should set updateOrCreate function.") Assert.IsFalse ( upsertConcurrentlyOperation.RequestOptions.EnableContentResponseOnWrite, "Upsert concurrently builder should disable content response." @@ -219,9 +219,8 @@ type BuilderUnitTests () = } Assert.IsTrue (operation.Id = "delete-id", "Delete builder should set id.") - Assert.IsTrue (operation.RequestOptions |> ValueOption.isSome, "Delete builder should initialize request options.") - - let options = operation.RequestOptions |> ValueOption.get + let options = + Assert.WantValueSome (operation.RequestOptions, "Delete builder should initialize request options.") Assert.IsTrue (options.IfNoneMatchEtag = "delete-etag", "Delete builder should set eTag.") Assert.IsTrue (options.SessionToken = "delete-session", "Delete builder should set session token.") diff --git a/tests/Cosmos.Tests/CosmosAssert.fs b/tests/Cosmos.Tests/CosmosAssert.fs index 2dc7274..6571565 100644 --- a/tests/Cosmos.Tests/CosmosAssert.fs +++ b/tests/Cosmos.Tests/CosmosAssert.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System open System.Diagnostics diff --git a/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs b/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs index 4dd36b5..487e0eb 100644 --- a/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs +++ b/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks diff --git a/tests/Cosmos.Tests/CreateOperationTests.fs b/tests/Cosmos.Tests/CreateOperationTests.fs index 3062d8d..fd0914e 100644 --- a/tests/Cosmos.Tests/CreateOperationTests.fs +++ b/tests/Cosmos.Tests/CreateOperationTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks diff --git a/tests/Cosmos.Tests/DeleteOperationTests.fs b/tests/Cosmos.Tests/DeleteOperationTests.fs index 0d4ae7b..25518a2 100644 --- a/tests/Cosmos.Tests/DeleteOperationTests.fs +++ b/tests/Cosmos.Tests/DeleteOperationTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index 768bf45..c8ecfe0 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -18,6 +18,7 @@ + diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs index 099a6c1..46011f1 100644 --- a/tests/Cosmos.Tests/IntegrationInfrastructure.fs +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System open System.Threading diff --git a/tests/Cosmos.Tests/IntegrationTestPlan.fs b/tests/Cosmos.Tests/IntegrationTestPlan.fs index d74b934..8409cc5 100644 --- a/tests/Cosmos.Tests/IntegrationTestPlan.fs +++ b/tests/Cosmos.Tests/IntegrationTestPlan.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration module IntegrationTestPlan = diff --git a/tests/Cosmos.Tests/IterationExtensionsTests.fs b/tests/Cosmos.Tests/IterationExtensionsTests.fs index 009b57a..5da81d8 100644 --- a/tests/Cosmos.Tests/IterationExtensionsTests.fs +++ b/tests/Cosmos.Tests/IterationExtensionsTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System.Threading.Tasks open FSharp.Control diff --git a/tests/Cosmos.Tests/OperationTestInfrastructure.fs b/tests/Cosmos.Tests/OperationTestInfrastructure.fs index 05c0b56..263e3c1 100644 --- a/tests/Cosmos.Tests/OperationTestInfrastructure.fs +++ b/tests/Cosmos.Tests/OperationTestInfrastructure.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System open System.Threading.Tasks diff --git a/tests/Cosmos.Tests/PatchOperationTests.fs b/tests/Cosmos.Tests/PatchOperationTests.fs index 9676d57..8db5ae7 100644 --- a/tests/Cosmos.Tests/PatchOperationTests.fs +++ b/tests/Cosmos.Tests/PatchOperationTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks diff --git a/tests/Cosmos.Tests/ReadManyOperationTests.fs b/tests/Cosmos.Tests/ReadManyOperationTests.fs index 615798e..ac9fec4 100644 --- a/tests/Cosmos.Tests/ReadManyOperationTests.fs +++ b/tests/Cosmos.Tests/ReadManyOperationTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks diff --git a/tests/Cosmos.Tests/ReadOperationTests.fs b/tests/Cosmos.Tests/ReadOperationTests.fs index 74a0808..25f5642 100644 --- a/tests/Cosmos.Tests/ReadOperationTests.fs +++ b/tests/Cosmos.Tests/ReadOperationTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks diff --git a/tests/Cosmos.Tests/ReplaceOperationTests.fs b/tests/Cosmos.Tests/ReplaceOperationTests.fs index f915813..05c90b0 100644 --- a/tests/Cosmos.Tests/ReplaceOperationTests.fs +++ b/tests/Cosmos.Tests/ReplaceOperationTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks diff --git a/tests/Cosmos.Tests/UpsertOperationTests.fs b/tests/Cosmos.Tests/UpsertOperationTests.fs index f775baa..fad153d 100644 --- a/tests/Cosmos.Tests/UpsertOperationTests.fs +++ b/tests/Cosmos.Tests/UpsertOperationTests.fs @@ -1,4 +1,4 @@ -namespace Tests.Integration +namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks From d63fbff76b291e182c24319f97df9800c293c414 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 14:33:40 +0000 Subject: [PATCH 17/33] Fix Assert extension signatures and keep test helper usage Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/6f71a1f0-316c-4294-88a6-23f8995238fc Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/Assert.fs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/Cosmos.Tests/Assert.fs b/tests/Cosmos.Tests/Assert.fs index c73402e..7c6e450 100644 --- a/tests/Cosmos.Tests/Assert.fs +++ b/tests/Cosmos.Tests/Assert.fs @@ -8,30 +8,30 @@ module AssertExtensions = type Assert with - static member WantSome (value, [] message) = + static member WantSome (value, [] message : string | null) = match value with | Some some -> some | None -> Assert.Fail (message) Unchecked.defaultof<_> - static member IsSome (value, [] message) = Assert.WantSome (value, message) |> ignore + static member IsSome (value, [] message : string | null) = Assert.WantSome (value, message) |> ignore - static member IsNone (value, [] message) = + static member IsNone (value, [] message : string | null) = match value with | Some _ -> Assert.Fail (message) | None -> () - static member WantValueSome (value, [] message) = + static member WantValueSome (value, [] message : string | null) = match value with | ValueSome some -> some | ValueNone -> Assert.Fail (message) Unchecked.defaultof<_> - static member IsValueSome (value, [] message) = Assert.WantValueSome (value, message) |> ignore + static member IsValueSome (value, [] message : string | null) = Assert.WantValueSome (value, message) |> ignore - static member IsValueNone (value, [] message) = + static member IsValueNone (value, [] message : string | null) = match value with | ValueSome _ -> Assert.Fail (message) | ValueNone -> () @@ -45,7 +45,7 @@ module AssertExtensions = | message -> Assert.Fail ($"'{message}': {error}") Unchecked.defaultof<_> - static member IsOk (value, [] message) = Assert.WantOk (value, message) |> ignore + static member IsOk (value, [] message : string | null) = Assert.WantOk (value, message) |> ignore static member WantError (value, [] message : string | null) = match value with @@ -56,17 +56,17 @@ module AssertExtensions = | message -> Assert.Fail ($"'{message}': {value}") Unchecked.defaultof<_> - static member IsError (value, [] message) = Assert.WantError (value, message) |> ignore + static member IsError (value, [] message : string | null) = Assert.WantError (value, message) |> ignore static member inline IsDefaultOf< ^T> (value : ^T, [] message : string) = Assert.AreEqual (box value, box Unchecked.defaultof< ^T>, message) - static member inline OkEquals< ^R, 'E> (expected : ^R, actual : Result< ^R, 'E >, [] message) = + static member inline OkEquals< ^R, 'E> (expected : ^R, actual : Result< ^R, 'E >, [] message : string | null) = Assert.AreEqual (box expected, box (Assert.WantOk (actual, message)), message) - static member inline ErrorEquals<'R, ^E> (expected : ^E, actual : Result<'R, ^E>, [] message) = + static member inline ErrorEquals<'R, ^E> (expected : ^E, actual : Result<'R, ^E>, [] message : string | null) = Assert.AreEqual (box expected, box (Assert.WantError (actual, message)), message) - static member FailWithData<'T> ([] message) = + static member FailWithData<'T> ([] message : string | null) = Assert.Fail (message) Unchecked.defaultof<'T> From 60e97b6773fa3e7b40cf9bba22a07277a01b2fed Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 24 May 2026 16:50:55 +0200 Subject: [PATCH 18/33] fixup! Split operation integration tests into per-operation files with AndRead coverage --- tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index c8ecfe0..5971707 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -22,7 +22,6 @@ - @@ -33,7 +32,6 @@ - From e1305d37c55ebe20977b58a39457d2d248e04a93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 15:14:30 +0000 Subject: [PATCH 19/33] Delete IntegrationTestPlan file per PR feedback Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/a905fb7f-9390-4144-84f7-22ff34a8fb8c Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../FSharp.Azure.Cosmos.Tests.fsproj | 1 - tests/Cosmos.Tests/IntegrationTestPlan.fs | 16 ---------------- 2 files changed, 17 deletions(-) delete mode 100644 tests/Cosmos.Tests/IntegrationTestPlan.fs diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index 5971707..e3180d3 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -21,7 +21,6 @@ - diff --git a/tests/Cosmos.Tests/IntegrationTestPlan.fs b/tests/Cosmos.Tests/IntegrationTestPlan.fs deleted file mode 100644 index 8409cc5..0000000 --- a/tests/Cosmos.Tests/IntegrationTestPlan.fs +++ /dev/null @@ -1,16 +0,0 @@ -namespace FSharp.Azure.Cosmos.Tests.Integration - -module IntegrationTestPlan = - - let plannedScenarios : string array = [| - "Create item: validates id/partition key persistence and response diagnostics." - "Read item: validates successful reads and not-found responses." - "Upsert item: validates insert-then-update behavior and returned resource state." - "Replace item: validates optimistic concurrency with ETag preconditions." - "Patch item: validates additive and replace patch operations for partial updates." - "Delete item: validates deletion and subsequent not-found behavior." - "Read many: validates batch read behavior across partitions." - "Iterator/TaskSeq reads: validates paging and continuation behavior." - "Unique key constraints: validates duplicate write failures." - "Seeded data scenarios: empty container, single partition set, multi-partition set." - |] From 5d796346fcee81c857b523a310b6a47d4327533d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 24 May 2026 17:41:35 +0200 Subject: [PATCH 20/33] Apply suggestions from code review Co-authored-by: Andrii Chebukin --- tests/Cosmos.Tests/CosmosAssert.fs | 1 + tests/Cosmos.Tests/IterationExtensionsTests.fs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Cosmos.Tests/CosmosAssert.fs b/tests/Cosmos.Tests/CosmosAssert.fs index 6571565..96b1edd 100644 --- a/tests/Cosmos.Tests/CosmosAssert.fs +++ b/tests/Cosmos.Tests/CosmosAssert.fs @@ -13,6 +13,7 @@ open Microsoft.VisualStudio.TestTools.UnitTesting [] type CosmosAssert private () = + static member private GetMessageOrDefault (message : string) (defaultMessage : string) = if String.IsNullOrWhiteSpace message then defaultMessage diff --git a/tests/Cosmos.Tests/IterationExtensionsTests.fs b/tests/Cosmos.Tests/IterationExtensionsTests.fs index 5da81d8..f3bc66f 100644 --- a/tests/Cosmos.Tests/IterationExtensionsTests.fs +++ b/tests/Cosmos.Tests/IterationExtensionsTests.fs @@ -27,8 +27,8 @@ type IterationExtensionsIntegrationTests () = |> TaskSeq.toListAsync let foundCount = iteratedItems - |> List.filter (fun item -> expectedIds.Contains item.id) - |> List.length + |> Seq.filter (fun item -> expectedIds.Contains item.id) + |> Seq.length Assert.IsTrue ((foundCount = 2), "FeedIterator.AsAsyncEnumerable should iterate seeded items.") } @@ -50,7 +50,7 @@ type IterationExtensionsIntegrationTests () = |> TaskSeq.toListAsync let foundCount = iteratedItems - |> List.filter (fun item -> expectedIds.Contains item.id) - |> List.length + |> Seq.filter (fun item -> expectedIds.Contains item.id) + |> Seq.length Assert.IsTrue ((foundCount = 2), "IQueryable.AsAsyncEnumerable should iterate seeded items.") } From f62634da3b3321afa52ea25bc37705abc6c9fd99 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 24 May 2026 21:01:21 +0200 Subject: [PATCH 21/33] fixup! ci(cosmos): use separate common action to check Azure Cosmos Emulator readiness --- .../Cosmos.Tests/IntegrationInfrastructure.fs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs index 46011f1..8bc90b4 100644 --- a/tests/Cosmos.Tests/IntegrationInfrastructure.fs +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -1,6 +1,9 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System +open System.Net +open System.Net.Http +open System.Net.Security open System.Threading open System.Threading.Tasks @@ -51,8 +54,30 @@ type DatabaseTestApplicationFactory (testContext : TestContext) = let buildDatabaseId () = testContext.GetTestDatabaseIdentifier () let databaseId = buildDatabaseId () + + let isLocalEmulatorHost (uri : Uri) = + uri.Host.Equals ("localhost", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals ("127.0.0.1", StringComparison.OrdinalIgnoreCase) + + let createHttpClient () = + let handler = new HttpClientHandler () + + handler.ServerCertificateCustomValidationCallback <- + (fun request _ _ errors -> + match request.RequestUri with + | null -> errors = SslPolicyErrors.None + | requestUri when errors = SslPolicyErrors.None -> true + | requestUri -> isLocalEmulatorHost requestUri + ) + + new HttpClient (handler, true) + let client = - new CosmosClient (endpoint, primaryKey, CosmosClientOptions (ConnectionMode = ConnectionMode.Gateway)) + new CosmosClient ( + endpoint, + primaryKey, + CosmosClientOptions (ConnectionMode = ConnectionMode.Gateway, HttpClientFactory = Func createHttpClient) + ) let mutable database = ValueNone member _.Client = client From cd491da9f4f312b7bfef18c93138d77f87e1c743 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 21:32:50 +0000 Subject: [PATCH 22/33] Fix failing Cosmos emulator tests Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/b2eda2fa-a0cb-4604-b8ca-51bd73075963 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/Cosmos/Cosmos.fs | 3 ++- tests/Cosmos.Tests/OperationTestInfrastructure.fs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs index 5f0e742..13e4069 100644 --- a/src/Cosmos/Cosmos.fs +++ b/src/Cosmos/Cosmos.fs @@ -223,7 +223,8 @@ module Operations = QueryDefinition( $"SELECT VALUE COUNT(1) \ FROM item \ - WHERE item.id = @Id AND IS_NULL(item.{deletedFieldName})" + WHERE item.id = @Id \ + AND (NOT IS_DEFINED(item.{deletedFieldName}) OR IS_NULL(item.{deletedFieldName}))" ) .WithParameter ("@Id", id) let! count = diff --git a/tests/Cosmos.Tests/OperationTestInfrastructure.fs b/tests/Cosmos.Tests/OperationTestInfrastructure.fs index 263e3c1..200dbdb 100644 --- a/tests/Cosmos.Tests/OperationTestInfrastructure.fs +++ b/tests/Cosmos.Tests/OperationTestInfrastructure.fs @@ -5,7 +5,8 @@ open System.Threading.Tasks open FSharp.Azure.Cosmos open Microsoft.Azure.Cosmos -type internal TestItem = { id : string; partitionKey : string; name : string; quantity : int } +[] +type TestItem = { id : string; partitionKey : string; name : string; quantity : int } [] type OperationTestBase () = From d53c998bc7e5c3b3ed7725050b867c881ba124be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 21:33:27 +0000 Subject: [PATCH 23/33] Handle undefined deleted marker in IsNotDeletedAsync Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/b2eda2fa-a0cb-4604-b8ca-51bd73075963 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/Cosmos/Cosmos.fs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs index 13e4069..b102108 100644 --- a/src/Cosmos/Cosmos.fs +++ b/src/Cosmos/Cosmos.fs @@ -221,10 +221,10 @@ module Operations = task { let query = QueryDefinition( - $"SELECT VALUE COUNT(1) \ - FROM item \ - WHERE item.id = @Id \ - AND (NOT IS_DEFINED(item.{deletedFieldName}) OR IS_NULL(item.{deletedFieldName}))" + $"""SELECT VALUE COUNT(1) + FROM item + WHERE item.id = @Id + AND (NOT IS_DEFINED(item.{deletedFieldName}) OR IS_NULL(item.{deletedFieldName}))""" ) .WithParameter ("@Id", id) let! count = From 829a0537149cbe72f1850ba14b5c7e3ee4071302 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 21:34:06 +0000 Subject: [PATCH 24/33] Validate deleted field name in IsNotDeletedAsync query Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/b2eda2fa-a0cb-4604-b8ca-51bd73075963 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/Cosmos/Cosmos.fs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs index b102108..5b21e35 100644 --- a/src/Cosmos/Cosmos.fs +++ b/src/Cosmos/Cosmos.fs @@ -219,6 +219,16 @@ module Operations = (id : string, [] requiestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken) = task { + let isValidDeletedFieldName = + not (String.IsNullOrWhiteSpace deletedFieldName) + && (deletedFieldName[0] = '_' || Char.IsLetter deletedFieldName[0]) + && deletedFieldName |> Seq.forall (fun c -> c = '_' || Char.IsLetterOrDigit c) + + if not isValidDeletedFieldName then + invalidArg + (nameof deletedFieldName) + "Deleted field name must start with a letter or underscore and contain only letters, digits, or underscores." + let query = QueryDefinition( $"""SELECT VALUE COUNT(1) From a06c43a351a4282aa925cbdac1a6230cd7a2b38c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 21:35:06 +0000 Subject: [PATCH 25/33] Harden IsNotDeletedAsync field-name validation Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/b2eda2fa-a0cb-4604-b8ca-51bd73075963 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/Cosmos/Cosmos.fs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs index 5b21e35..fc53b5e 100644 --- a/src/Cosmos/Cosmos.fs +++ b/src/Cosmos/Cosmos.fs @@ -215,14 +215,22 @@ module Operations = /// Partition key /// Cancellation token member container.IsNotDeletedAsync - deletedFieldName + (deletedFieldName : string | null) (id : string, [] requiestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken) = task { + let isAsciiLetter c = ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') + let isAsciiDigit c = '0' <= c && c <= '9' + let isValidDeletedFieldName = - not (String.IsNullOrWhiteSpace deletedFieldName) - && (deletedFieldName[0] = '_' || Char.IsLetter deletedFieldName[0]) - && deletedFieldName |> Seq.forall (fun c -> c = '_' || Char.IsLetterOrDigit c) + match deletedFieldName with + | null -> false + | name when String.IsNullOrWhiteSpace name -> false + | name -> + let firstCharacter = name[0] + let hasValidStart = firstCharacter = '_' || isAsciiLetter firstCharacter + let hasValidBody = name |> Seq.forall (fun c -> c = '_' || isAsciiLetter c || isAsciiDigit c) + hasValidStart && hasValidBody if not isValidDeletedFieldName then invalidArg From 5db799cccb4022bc62eab14094e68bc680f9ffdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 22:19:25 +0000 Subject: [PATCH 26/33] Address review feedback for nullArg, test categories, and scenario seeding Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/fb87fcd3-5c25-4e1f-ab2c-0f29ce614280 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/Cosmos/Cosmos.fs | 25 ++++--- tests/Cosmos.Tests/BuilderUnitTests.fs | 2 +- .../Cosmos.Tests/CosmosReadExtensionsTests.fs | 51 ++++++++++++- tests/Cosmos.Tests/CreateOperationTests.fs | 3 +- tests/Cosmos.Tests/DeleteOperationTests.fs | 3 +- .../FSharp.Azure.Cosmos.Tests.fsproj | 1 + .../Cosmos.Tests/IntegrationInfrastructure.fs | 35 +++++++-- .../Cosmos.Tests/IterationExtensionsTests.fs | 21 ++++-- .../OperationTestInfrastructure.fs | 26 +++---- tests/Cosmos.Tests/PatchOperationTests.fs | 3 +- tests/Cosmos.Tests/ReadManyOperationTests.fs | 73 ++++++++++++------- tests/Cosmos.Tests/ReadOperationTests.fs | 44 ++++++++--- tests/Cosmos.Tests/ReplaceOperationTests.fs | 3 +- tests/Cosmos.Tests/TestCategories.fs | 33 +++++++++ tests/Cosmos.Tests/UpsertOperationTests.fs | 3 +- 15 files changed, 244 insertions(+), 82 deletions(-) create mode 100644 tests/Cosmos.Tests/TestCategories.fs diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs index fc53b5e..c379fcc 100644 --- a/src/Cosmos/Cosmos.fs +++ b/src/Cosmos/Cosmos.fs @@ -219,27 +219,34 @@ module Operations = (id : string, [] requiestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken) = task { + let deletedFieldName = + match deletedFieldName with + | null -> nullArg (nameof deletedFieldName) + | deletedFieldName -> deletedFieldName + let isAsciiLetter c = ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') let isAsciiDigit c = '0' <= c && c <= '9' let isValidDeletedFieldName = - match deletedFieldName with - | null -> false - | name when String.IsNullOrWhiteSpace name -> false - | name -> - let firstCharacter = name[0] + if String.IsNullOrWhiteSpace deletedFieldName then + false + else + let firstCharacter = deletedFieldName[0] let hasValidStart = firstCharacter = '_' || isAsciiLetter firstCharacter - let hasValidBody = name |> Seq.forall (fun c -> c = '_' || isAsciiLetter c || isAsciiDigit c) + let hasValidBody = + deletedFieldName + |> Seq.forall (fun c -> c = '_' || isAsciiLetter c || isAsciiDigit c) + hasValidStart && hasValidBody if not isValidDeletedFieldName then invalidArg - (nameof deletedFieldName) - "Deleted field name must start with a letter or underscore and contain only letters, digits, or underscores." + (nameof deletedFieldName) + "Deleted field name must start with a letter or underscore and contain only letters, digits, or underscores." let query = QueryDefinition( - $"""SELECT VALUE COUNT(1) + $"""SELECT VALUE COUNT(1) FROM item WHERE item.id = @Id AND (NOT IS_DEFINED(item.{deletedFieldName}) OR IS_NULL(item.{deletedFieldName}))""" diff --git a/tests/Cosmos.Tests/BuilderUnitTests.fs b/tests/Cosmos.Tests/BuilderUnitTests.fs index 3eb5478..2743bf2 100644 --- a/tests/Cosmos.Tests/BuilderUnitTests.fs +++ b/tests/Cosmos.Tests/BuilderUnitTests.fs @@ -7,7 +7,7 @@ open Microsoft.VisualStudio.TestTools.UnitTesting type private BuilderTestItem = { id : string; partitionKey : string; value : int } -[] +[] type BuilderUnitTests () = [] diff --git a/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs b/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs index 487e0eb..05502d0 100644 --- a/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs +++ b/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs @@ -1,12 +1,14 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net +open System open System.Threading.Tasks open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests open Microsoft.Azure.Cosmos open Microsoft.VisualStudio.TestTools.UnitTesting -[] +[] type CosmosReadExtensionsIntegrationTests () = inherit OperationTestBase () @@ -48,6 +50,13 @@ type CosmosReadExtensionsIntegrationTests () = Assert.IsTrue (notDeletedBeforePatch, "IsNotDeletedAsync should return true before deleted marker is set.") + let! notDeletedWithUnderscoreFieldName = container.IsNotDeletedAsync "_deletedAt" secondItem.id + + Assert.IsTrue ( + notDeletedWithUnderscoreFieldName, + "IsNotDeletedAsync should support deleted field names starting with underscore." + ) + let! patchResponse = container.ExecuteOverwriteAsync ( patch { @@ -66,3 +75,43 @@ type CosmosReadExtensionsIntegrationTests () = Assert.IsFalse (notDeletedAfterPatch, "IsNotDeletedAsync should return false after deleted marker is set.") } + + [] + member this.``IsNotDeletedAsync throws for invalid deleted field names`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "invalid-deleted-field-name" + let invokeIsNotDeleted (deletedFieldName : string | null) = + Func (fun () -> + task { + let! _ = container.IsNotDeletedAsync deletedFieldName testItem.id + return () + } + :> Task + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted null, + "IsNotDeletedAsync should throw ArgumentNullException when deleted field name is null." + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted " ", + "IsNotDeletedAsync should throw ArgumentException when deleted field name is whitespace." + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "1deletedAt", + "IsNotDeletedAsync should throw ArgumentException when deleted field name starts with a digit." + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "deleted-at", + "IsNotDeletedAsync should throw ArgumentException when deleted field name has unsupported characters." + ) + + return () + } diff --git a/tests/Cosmos.Tests/CreateOperationTests.fs b/tests/Cosmos.Tests/CreateOperationTests.fs index fd0914e..143541b 100644 --- a/tests/Cosmos.Tests/CreateOperationTests.fs +++ b/tests/Cosmos.Tests/CreateOperationTests.fs @@ -3,9 +3,10 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests open Microsoft.VisualStudio.TestTools.UnitTesting -[] +[] type CreateOperationIntegrationTests () = inherit OperationTestBase () diff --git a/tests/Cosmos.Tests/DeleteOperationTests.fs b/tests/Cosmos.Tests/DeleteOperationTests.fs index 25518a2..ed22f8e 100644 --- a/tests/Cosmos.Tests/DeleteOperationTests.fs +++ b/tests/Cosmos.Tests/DeleteOperationTests.fs @@ -3,9 +3,10 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests open Microsoft.VisualStudio.TestTools.UnitTesting -[] +[] type DeleteOperationIntegrationTests () = inherit OperationTestBase () diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index e3180d3..8e3b214 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -20,6 +20,7 @@ + diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs index 8bc90b4..6c1d790 100644 --- a/tests/Cosmos.Tests/IntegrationInfrastructure.fs +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -97,6 +97,25 @@ type DatabaseTestApplicationFactory (testContext : TestContext) = database <- ValueNone } + member _.GetOrCreateContainerAsync + (containerId : string, partitionKeyPath : string, cancellationToken : CancellationToken) + : Task + = + task { + let database = + match database with + | ValueSome existingDatabase -> existingDatabase + | ValueNone -> invalidOp "Database is not initialized." + + let! containerResponse = + database.CreateContainerIfNotExistsAsync ( + ContainerProperties (containerId, partitionKeyPath), + cancellationToken = cancellationToken + ) + + return containerResponse.Container + } + abstract SeedDataAsync : cancellationToken : CancellationToken -> Task default _.SeedDataAsync (cancellationToken : CancellationToken) = Task.CompletedTask @@ -109,18 +128,19 @@ type DatabaseTestApplicationFactory (testContext : TestContext) = |> ValueTask [] -type IntegrationTestBase () = +type IntegrationTestBase<'DatabaseTestApplicationFactory when 'DatabaseTestApplicationFactory :> DatabaseTestApplicationFactory> + () + = inherit TestBase () - member val private application : DatabaseTestApplicationFactory voption = ValueNone with get, set + member val private application : 'DatabaseTestApplicationFactory voption = ValueNone with get, set member this.Application = match this.application with - | ValueNone -> invalidOp "Application not initialized. Ensure test runs within TestInitialize/TestCleanup lifecycle." | ValueSome application -> application + | ValueNone -> invalidOp "Application not initialized. Ensure test runs within TestInitialize/TestCleanup lifecycle." - abstract CreateApplication : TestContext -> DatabaseTestApplicationFactory - default _.CreateApplication context = DatabaseTestApplicationFactory (context) + abstract CreateApplication : TestContext -> 'DatabaseTestApplicationFactory [] member this.Initialize () : Task = task { @@ -138,3 +158,8 @@ type IntegrationTestBase () = do! (application :> IAsyncDisposable).DisposeAsync().AsTask () this.application <- ValueNone } + +type IntegrationTestBase () = + inherit IntegrationTestBase () + + override _.CreateApplication context = DatabaseTestApplicationFactory (context) diff --git a/tests/Cosmos.Tests/IterationExtensionsTests.fs b/tests/Cosmos.Tests/IterationExtensionsTests.fs index f3bc66f..0dd7316 100644 --- a/tests/Cosmos.Tests/IterationExtensionsTests.fs +++ b/tests/Cosmos.Tests/IterationExtensionsTests.fs @@ -2,20 +2,24 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System.Threading.Tasks open FSharp.Control +open FSharp.Azure.Cosmos.Tests open Microsoft.Azure.Cosmos open Microsoft.Azure.Cosmos.Linq open Microsoft.VisualStudio.TestTools.UnitTesting -[] +[] type IterationExtensionsIntegrationTests () = - inherit OperationTestBase () + inherit OperationTestBase () + + override _.CreateApplication context = MultipleItemsScenario (context) [] member this.``FeedIterator AsAsyncEnumerable iterates seeded items`` () : Task = task { let! container = this.GetContainer () - let firstItem = this.NewItem "iterator-1" - let secondItem = this.NewItem "iterator-2" - do! this.SeedItemsAsync (container, [ firstItem; secondItem ]) + let firstItem, secondItem = + match this.Application.SeededItems with + | [ firstItem; secondItem ] -> firstItem, secondItem + | seededItems -> failwith $"Expected exactly two seeded items but got {seededItems.Length}." let query = QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @partitionKey").WithParameter ("@partitionKey", "integration") @@ -35,9 +39,10 @@ type IterationExtensionsIntegrationTests () = [] member this.``IQueryable AsAsyncEnumerable iterates seeded items`` () : Task = task { let! container = this.GetContainer () - let firstItem = this.NewItem "queryable-1" - let secondItem = this.NewItem "queryable-2" - do! this.SeedItemsAsync (container, [ firstItem; secondItem ]) + let firstItem, secondItem = + match this.Application.SeededItems with + | [ firstItem; secondItem ] -> firstItem, secondItem + | seededItems -> failwith $"Expected exactly two seeded items but got {seededItems.Length}." let queryable = container.GetItemLinqQueryable ( diff --git a/tests/Cosmos.Tests/OperationTestInfrastructure.fs b/tests/Cosmos.Tests/OperationTestInfrastructure.fs index 200dbdb..3478351 100644 --- a/tests/Cosmos.Tests/OperationTestInfrastructure.fs +++ b/tests/Cosmos.Tests/OperationTestInfrastructure.fs @@ -9,26 +9,14 @@ open Microsoft.Azure.Cosmos type TestItem = { id : string; partitionKey : string; name : string; quantity : int } [] -type OperationTestBase () = - inherit IntegrationTestBase () +type OperationTestBase<'DatabaseTestApplicationFactory when 'DatabaseTestApplicationFactory :> DatabaseTestApplicationFactory> () + = + inherit IntegrationTestBase<'DatabaseTestApplicationFactory> () let containerId = "operation-tests" - member private this.GetDatabase () = - match this.Application.Database with - | ValueSome database -> database - | ValueNone -> invalidOp "Database is not initialized." - member this.GetContainer () : Task = task { - let database = this.GetDatabase () - - let! containerResponse = - database.CreateContainerIfNotExistsAsync ( - ContainerProperties (containerId, "/partitionKey"), - cancellationToken = this.CancellationToken - ) - - return containerResponse.Container + return! this.Application.GetOrCreateContainerAsync (containerId, "/partitionKey", this.CancellationToken) } member internal this.NewItem (suffix : string) : TestItem = { @@ -51,3 +39,9 @@ type OperationTestBase () = CosmosAssert.IsOk (createResponse.Result, $"Seed create should succeed for item '{seedItem.id}'.") } + +[] +type OperationTestBase () = + inherit OperationTestBase () + + override _.CreateApplication context = DatabaseTestApplicationFactory (context) diff --git a/tests/Cosmos.Tests/PatchOperationTests.fs b/tests/Cosmos.Tests/PatchOperationTests.fs index 8db5ae7..f7d907a 100644 --- a/tests/Cosmos.Tests/PatchOperationTests.fs +++ b/tests/Cosmos.Tests/PatchOperationTests.fs @@ -3,10 +3,11 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests open Microsoft.Azure.Cosmos open Microsoft.VisualStudio.TestTools.UnitTesting -[] +[] type PatchOperationIntegrationTests () = inherit OperationTestBase () diff --git a/tests/Cosmos.Tests/ReadManyOperationTests.fs b/tests/Cosmos.Tests/ReadManyOperationTests.fs index ac9fec4..7646a0e 100644 --- a/tests/Cosmos.Tests/ReadManyOperationTests.fs +++ b/tests/Cosmos.Tests/ReadManyOperationTests.fs @@ -1,42 +1,63 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net +open System.Threading open System.Threading.Tasks open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests open Microsoft.Azure.Cosmos open Microsoft.VisualStudio.TestTools.UnitTesting -[] -type ReadManyOperationIntegrationTests () = - inherit OperationTestBase () +type MultipleItemsScenario (testContext : TestContext) as this = + inherit DatabaseTestApplicationFactory (testContext) - [] - member this.``ReadMany execute returns matching items`` () : Task = task { - let! container = this.GetContainer () - let firstItem = this.NewItem "readmany-1" - let secondItem = this.NewItem "readmany-2" + let containerId = "operation-tests" - let! firstCreateResponse = - container.ExecuteAsync ( - create { - item firstItem - partitionKey firstItem.partitionKey - }, - this.CancellationToken - ) + let firstSeededItem : TestItem = { + id = $"{testContext.TestName}-readmany-1" + partitionKey = "integration" + name = "item-readmany-1" + quantity = 1 + } - CosmosAssert.IsOk (firstCreateResponse.Result, "First seed create should succeed.") + let secondSeededItem : TestItem = { + id = $"{testContext.TestName}-readmany-2" + partitionKey = "integration" + name = "item-readmany-2" + quantity = 2 + } - let! secondCreateResponse = - container.ExecuteAsync ( - create { - item secondItem - partitionKey secondItem.partitionKey - }, - this.CancellationToken - ) + member _.SeededItems = [ firstSeededItem; secondSeededItem ] + + override _.SeedDataAsync (cancellationToken : CancellationToken) : Task = task { + let! container = this.GetOrCreateContainerAsync (containerId, "/partitionKey", cancellationToken) + + for seededItem in this.SeededItems do + let! createResponse = + container.ExecuteAsync ( + create { + item seededItem + partitionKey seededItem.partitionKey + }, + cancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, $"ReadMany scenario seed create should succeed for '{seededItem.id}'.") + } - CosmosAssert.IsOk (secondCreateResponse.Result, "Second seed create should succeed.") +[] +type ReadManyOperationIntegrationTests () = + inherit OperationTestBase () + + override _.CreateApplication context = MultipleItemsScenario (context) + + [] + member this.``ReadMany execute returns matching items`` () : Task = task { + let! container = this.GetContainer () + let firstItem, secondItem = + match this.Application.SeededItems with + | [ firstItem; secondItem ] -> firstItem, secondItem + | seededItems -> failwith $"Expected exactly two seeded items but got {seededItems.Length}." let! readManyResponse = container.ExecuteAsync ( diff --git a/tests/Cosmos.Tests/ReadOperationTests.fs b/tests/Cosmos.Tests/ReadOperationTests.fs index 25f5642..a758825 100644 --- a/tests/Cosmos.Tests/ReadOperationTests.fs +++ b/tests/Cosmos.Tests/ReadOperationTests.fs @@ -1,29 +1,51 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net +open System.Threading open System.Threading.Tasks open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests open Microsoft.VisualStudio.TestTools.UnitTesting -[] -type ReadOperationIntegrationTests () = - inherit OperationTestBase () +type SingleItemScenario (testContext : TestContext) as this = + inherit DatabaseTestApplicationFactory (testContext) - [] - member this.``Read execute returns existing and not found states`` () : Task = task { - let! container = this.GetContainer () - let testItem = this.NewItem "read" + let containerId = "operation-tests" + + let seededItem : TestItem = { + id = $"{testContext.TestName}-read" + partitionKey = "integration" + name = "item-read" + quantity = 1 + } + + member _.SeededItem = seededItem + + override _.SeedDataAsync (cancellationToken : CancellationToken) : Task = task { + let! container = this.GetOrCreateContainerAsync (containerId, "/partitionKey", cancellationToken) let! createResponse = container.ExecuteAsync ( create { - item testItem - partitionKey testItem.partitionKey + item seededItem + partitionKey seededItem.partitionKey }, - this.CancellationToken + cancellationToken ) - CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + CosmosAssert.IsOk (createResponse.Result, "Read scenario seed create should succeed.") + } + +[] +type ReadOperationIntegrationTests () = + inherit OperationTestBase () + + override _.CreateApplication context = SingleItemScenario (context) + + [] + member this.``Read execute returns existing and not found states`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.Application.SeededItem let! foundResponse = container.ExecuteAsync ( diff --git a/tests/Cosmos.Tests/ReplaceOperationTests.fs b/tests/Cosmos.Tests/ReplaceOperationTests.fs index 05c90b0..aafc6df 100644 --- a/tests/Cosmos.Tests/ReplaceOperationTests.fs +++ b/tests/Cosmos.Tests/ReplaceOperationTests.fs @@ -3,9 +3,10 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests open Microsoft.VisualStudio.TestTools.UnitTesting -[] +[] type ReplaceOperationIntegrationTests () = inherit OperationTestBase () diff --git a/tests/Cosmos.Tests/TestCategories.fs b/tests/Cosmos.Tests/TestCategories.fs new file mode 100644 index 0000000..6ee0c07 --- /dev/null +++ b/tests/Cosmos.Tests/TestCategories.fs @@ -0,0 +1,33 @@ +namespace FSharp.Azure.Cosmos.Tests + +[] +module TestCategories = + [] + let Builders = "Builders" + + [] + let Create = "Create" + + [] + let Read = "Read" + + [] + let ReadMany = "ReadMany" + + [] + let Upsert = "Upsert" + + [] + let Replace = "Replace" + + [] + let Patch = "Patch" + + [] + let Delete = "Delete" + + [] + let ReadExtensions = "ReadExtensions" + + [] + let IterationExtensions = "IterationExtensions" diff --git a/tests/Cosmos.Tests/UpsertOperationTests.fs b/tests/Cosmos.Tests/UpsertOperationTests.fs index fad153d..791f889 100644 --- a/tests/Cosmos.Tests/UpsertOperationTests.fs +++ b/tests/Cosmos.Tests/UpsertOperationTests.fs @@ -3,9 +3,10 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System.Net open System.Threading.Tasks open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests open Microsoft.VisualStudio.TestTools.UnitTesting -[] +[] type UpsertOperationIntegrationTests () = inherit OperationTestBase () From 842d5edfd8f8f4abbce133505d0192fee646d871 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 22:20:17 +0000 Subject: [PATCH 27/33] Apply validation feedback ordering in IntegrationTestBase Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/fb87fcd3-5c25-4e1f-ab2c-0f29ce614280 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/IntegrationInfrastructure.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs index 6c1d790..31d1c10 100644 --- a/tests/Cosmos.Tests/IntegrationInfrastructure.fs +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -137,8 +137,8 @@ type IntegrationTestBase<'DatabaseTestApplicationFactory when 'DatabaseTestAppli member this.Application = match this.application with - | ValueSome application -> application | ValueNone -> invalidOp "Application not initialized. Ensure test runs within TestInitialize/TestCleanup lifecycle." + | ValueSome application -> application abstract CreateApplication : TestContext -> 'DatabaseTestApplicationFactory From bde1f0df4f59c4244c318bb9fcf17c8993104656 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 22:21:39 +0000 Subject: [PATCH 28/33] Simplify async exception test delegate in read extensions tests Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/fb87fcd3-5c25-4e1f-ab2c-0f29ce614280 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/CosmosReadExtensionsTests.fs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs b/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs index 05502d0..127c132 100644 --- a/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs +++ b/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs @@ -81,13 +81,10 @@ type CosmosReadExtensionsIntegrationTests () = let! container = this.GetContainer () let testItem = this.NewItem "invalid-deleted-field-name" let invokeIsNotDeleted (deletedFieldName : string | null) = - Func (fun () -> - task { - let! _ = container.IsNotDeletedAsync deletedFieldName testItem.id - return () - } - :> Task - ) + Func (fun () -> task { + let! _ = container.IsNotDeletedAsync deletedFieldName testItem.id + return () + }) let! _ = Assert.ThrowsExactlyAsync ( From 7badc2f3f16fa9a0088c810dfea12f3e068a9342 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 25 May 2026 01:03:10 +0200 Subject: [PATCH 29/33] fix `ReadExtensionsIntegrationTests` name --- tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj | 2 +- .../{CosmosReadExtensionsTests.fs => ReadExtensionsTests.fs} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename tests/Cosmos.Tests/{CosmosReadExtensionsTests.fs => ReadExtensionsTests.fs} (99%) diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index 8e3b214..e186657 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -29,7 +29,7 @@ - + diff --git a/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs b/tests/Cosmos.Tests/ReadExtensionsTests.fs similarity index 99% rename from tests/Cosmos.Tests/CosmosReadExtensionsTests.fs rename to tests/Cosmos.Tests/ReadExtensionsTests.fs index 127c132..ec3a44b 100644 --- a/tests/Cosmos.Tests/CosmosReadExtensionsTests.fs +++ b/tests/Cosmos.Tests/ReadExtensionsTests.fs @@ -9,7 +9,7 @@ open Microsoft.Azure.Cosmos open Microsoft.VisualStudio.TestTools.UnitTesting [] -type CosmosReadExtensionsIntegrationTests () = +type ReadExtensionsIntegrationTests () = inherit OperationTestBase () [] From 69776da917363c708a7e09d6812bb600739fb2d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 23:11:03 +0000 Subject: [PATCH 30/33] Refine IsNotDeletedAsync docs and validation coverage Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/7c652b6b-140a-42c5-9e86-a71d0a69c5ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/Cosmos/Cosmos.fs | 32 ++++++++++++++++++----- tests/Cosmos.Tests/ReadExtensionsTests.fs | 17 ++++++++++-- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs index c379fcc..7eb3d79 100644 --- a/src/Cosmos/Cosmos.fs +++ b/src/Cosmos/Cosmos.fs @@ -70,6 +70,10 @@ module Operations = type ItemRequestOptions with + /// + /// Adds a pre-trigger to request options. + /// + /// Trigger name. member options.AddPreTrigger (trigger : string) = options.PreTriggers <- [| if not <| isNull options.PreTriggers then @@ -77,6 +81,11 @@ module Operations = yield trigger |] + /// + /// Adds pre-triggers to request options. + /// + /// Trigger names. + /// Thrown when is null. member options.AddPreTriggers (triggers : string seq) = if obj.ReferenceEquals (triggers, null) then raise (ArgumentNullException (nameof triggers)) @@ -93,6 +102,11 @@ module Operations = yield trigger |] + /// + /// Adds post-triggers to request options. + /// + /// Trigger names. + /// Thrown when is null. member options.AddPostTriggers (triggers : string seq) = if obj.ReferenceEquals (triggers, null) then raise (ArgumentNullException (nameof triggers)) @@ -211,19 +225,23 @@ module Operations = /// /// Checks if an item with specified Id exists in the container partition with specified key. /// + /// Deleted marker field name. /// Item Id - /// Partition key + /// Request options /// Cancellation token + /// Thrown when is null. + /// + /// Thrown when does not start with a letter or underscore, + /// or contains characters other than letters, digits, or underscores. + /// member container.IsNotDeletedAsync - (deletedFieldName : string | null) + (deletedFieldName : string) (id : string, [] requiestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken) = - task { - let deletedFieldName = - match deletedFieldName with - | null -> nullArg (nameof deletedFieldName) - | deletedFieldName -> deletedFieldName + if obj.ReferenceEquals (deletedFieldName, null) then + nullArg (nameof deletedFieldName) + task { let isAsciiLetter c = ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') let isAsciiDigit c = '0' <= c && c <= '9' diff --git a/tests/Cosmos.Tests/ReadExtensionsTests.fs b/tests/Cosmos.Tests/ReadExtensionsTests.fs index ec3a44b..22fda15 100644 --- a/tests/Cosmos.Tests/ReadExtensionsTests.fs +++ b/tests/Cosmos.Tests/ReadExtensionsTests.fs @@ -57,6 +57,13 @@ type ReadExtensionsIntegrationTests () = "IsNotDeletedAsync should support deleted field names starting with underscore." ) + let! notDeletedWithDigitInBodyFieldName = container.IsNotDeletedAsync "deletedAt1" secondItem.id + + Assert.IsTrue ( + notDeletedWithDigitInBodyFieldName, + "IsNotDeletedAsync should support deleted field names with digits after the first character." + ) + let! patchResponse = container.ExecuteOverwriteAsync ( patch { @@ -80,7 +87,7 @@ type ReadExtensionsIntegrationTests () = member this.``IsNotDeletedAsync throws for invalid deleted field names`` () : Task = task { let! container = this.GetContainer () let testItem = this.NewItem "invalid-deleted-field-name" - let invokeIsNotDeleted (deletedFieldName : string | null) = + let invokeIsNotDeleted (deletedFieldName : string) = Func (fun () -> task { let! _ = container.IsNotDeletedAsync deletedFieldName testItem.id return () @@ -88,10 +95,16 @@ type ReadExtensionsIntegrationTests () = let! _ = Assert.ThrowsExactlyAsync ( - invokeIsNotDeleted null, + invokeIsNotDeleted Unchecked.defaultof, "IsNotDeletedAsync should throw ArgumentNullException when deleted field name is null." ) + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "", + "IsNotDeletedAsync should throw ArgumentException when deleted field name is empty." + ) + let! _ = Assert.ThrowsExactlyAsync ( invokeIsNotDeleted " ", From 1a733408cee6811f2c032cdee889a5c15fc09e6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 23:12:06 +0000 Subject: [PATCH 31/33] Address follow-up review notes for IsNotDeletedAsync Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/7c652b6b-140a-42c5-9e86-a71d0a69c5ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/Cosmos/Cosmos.fs | 4 ++-- tests/Cosmos.Tests/ReadExtensionsTests.fs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs index 7eb3d79..f596c26 100644 --- a/src/Cosmos/Cosmos.fs +++ b/src/Cosmos/Cosmos.fs @@ -236,7 +236,7 @@ module Operations = /// member container.IsNotDeletedAsync (deletedFieldName : string) - (id : string, [] requiestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken) + (id : string, [] requestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken) = if obj.ReferenceEquals (deletedFieldName, null) then nullArg (nameof deletedFieldName) @@ -273,7 +273,7 @@ module Operations = let! count = container.GetItemQueryIterator ( query, - requestOptions = getRequestOptionsWithMaxItemCount1 requiestOptions + requestOptions = getRequestOptionsWithMaxItemCount1 requestOptions ) |> CancellableTaskSeq.ofFeedIterator cancellationToken |> TaskSeq.tryHead diff --git a/tests/Cosmos.Tests/ReadExtensionsTests.fs b/tests/Cosmos.Tests/ReadExtensionsTests.fs index 22fda15..c6c264d 100644 --- a/tests/Cosmos.Tests/ReadExtensionsTests.fs +++ b/tests/Cosmos.Tests/ReadExtensionsTests.fs @@ -95,7 +95,10 @@ type ReadExtensionsIntegrationTests () = let! _ = Assert.ThrowsExactlyAsync ( - invokeIsNotDeleted Unchecked.defaultof, + Func (fun () -> task { + let! _ = container.IsNotDeletedAsync Unchecked.defaultof testItem.id + return () + }), "IsNotDeletedAsync should throw ArgumentNullException when deleted field name is null." ) From b9c6eae87288cd28f7eb3acadcbfa43cd67b5a8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 23:12:51 +0000 Subject: [PATCH 32/33] Tidy read extensions test variable naming Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/7c652b6b-140a-42c5-9e86-a71d0a69c5ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/ReadExtensionsTests.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Cosmos.Tests/ReadExtensionsTests.fs b/tests/Cosmos.Tests/ReadExtensionsTests.fs index c6c264d..47a625b 100644 --- a/tests/Cosmos.Tests/ReadExtensionsTests.fs +++ b/tests/Cosmos.Tests/ReadExtensionsTests.fs @@ -57,10 +57,10 @@ type ReadExtensionsIntegrationTests () = "IsNotDeletedAsync should support deleted field names starting with underscore." ) - let! notDeletedWithDigitInBodyFieldName = container.IsNotDeletedAsync "deletedAt1" secondItem.id + let! notDeletedWithDigit = container.IsNotDeletedAsync "deletedAt1" secondItem.id Assert.IsTrue ( - notDeletedWithDigitInBodyFieldName, + notDeletedWithDigit, "IsNotDeletedAsync should support deleted field names with digits after the first character." ) From 985d3f94f9afd773b75ee2692468ebb0c2eea6b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 23:13:52 +0000 Subject: [PATCH 33/33] Expand deleted-field validation coverage in read extension tests Agent-Logs-Url: https://github.com/fsprojects/FSharp.Azure.Cosmos/sessions/7c652b6b-140a-42c5-9e86-a71d0a69c5ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/Cosmos.Tests/ReadExtensionsTests.fs | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Cosmos.Tests/ReadExtensionsTests.fs b/tests/Cosmos.Tests/ReadExtensionsTests.fs index 47a625b..829ac4a 100644 --- a/tests/Cosmos.Tests/ReadExtensionsTests.fs +++ b/tests/Cosmos.Tests/ReadExtensionsTests.fs @@ -64,6 +64,28 @@ type ReadExtensionsIntegrationTests () = "IsNotDeletedAsync should support deleted field names with digits after the first character." ) + let! digitFieldPatchResponse = + container.ExecuteOverwriteAsync ( + patch { + id firstItem.id + partitionKey firstItem.partitionKey + operation (PatchOperation.Set ("/deletedAt1", "2026-05-24T00:00:00Z")) + }, + this.CancellationToken + ) + + match digitFieldPatchResponse.Result with + | PatchResult.Ok _ -> + Assert.IsTrue (digitFieldPatchResponse.HttpStatusCode = HttpStatusCode.OK, "Patch should return HTTP 200.") + | result -> Assert.Fail ($"Expected patch success for digit-field marker, got {result}.") + + let! notDeletedWithDigitAfterPatch = container.IsNotDeletedAsync "deletedAt1" firstItem.id + + Assert.IsFalse ( + notDeletedWithDigitAfterPatch, + "IsNotDeletedAsync should return false when a digit-containing deleted marker field is set." + ) + let! patchResponse = container.ExecuteOverwriteAsync ( patch { @@ -120,11 +142,23 @@ type ReadExtensionsIntegrationTests () = "IsNotDeletedAsync should throw ArgumentException when deleted field name starts with a digit." ) + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "1deleted", + "IsNotDeletedAsync should throw ArgumentException for field names that start with a digit." + ) + let! _ = Assert.ThrowsExactlyAsync ( invokeIsNotDeleted "deleted-at", "IsNotDeletedAsync should throw ArgumentException when deleted field name has unsupported characters." ) + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "deleted-field", + "IsNotDeletedAsync should throw ArgumentException for field names with hyphens." + ) + return () }