diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs
index 5f0e742..f596c26 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,25 +225,55 @@ 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
- (id : string, [] requiestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken)
+ (deletedFieldName : string)
+ (id : string, [] requestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken)
=
+ 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'
+
+ let isValidDeletedFieldName =
+ if String.IsNullOrWhiteSpace deletedFieldName then
+ false
+ else
+ let firstCharacter = deletedFieldName[0]
+ let hasValidStart = firstCharacter = '_' || isAsciiLetter firstCharacter
+ 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."
+
let query =
QueryDefinition(
- $"SELECT VALUE COUNT(1) \
- FROM item \
- WHERE item.id = @Id AND 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 =
container.GetItemQueryIterator (
query,
- requestOptions = getRequestOptionsWithMaxItemCount1 requiestOptions
+ requestOptions = getRequestOptionsWithMaxItemCount1 requestOptions
)
|> CancellableTaskSeq.ofFeedIterator cancellationToken
|> TaskSeq.tryHead
diff --git a/tests/Cosmos.Tests/Assert.fs b/tests/Cosmos.Tests/Assert.fs
new file mode 100644
index 0000000..7c6e450
--- /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 : string | null) =
+ match value with
+ | Some some -> some
+ | None ->
+ Assert.Fail (message)
+ Unchecked.defaultof<_>
+
+ static member IsSome (value, [] message : string | null) = Assert.WantSome (value, message) |> ignore
+
+ static member IsNone (value, [] message : string | null) =
+ match value with
+ | Some _ -> Assert.Fail (message)
+ | None -> ()
+
+ static member WantValueSome (value, [] message : string | null) =
+ match value with
+ | ValueSome some -> some
+ | ValueNone ->
+ Assert.Fail (message)
+ Unchecked.defaultof<_>
+
+ static member IsValueSome (value, [] message : string | null) = Assert.WantValueSome (value, message) |> ignore
+
+ static member IsValueNone (value, [] message : string | null) =
+ 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 : string | null) = 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 : 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 : 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 : string | null) =
+ Assert.AreEqual (box expected, box (Assert.WantError (actual, message)), message)
+
+ static member FailWithData<'T> ([] message : string | null) =
+ Assert.Fail (message)
+ Unchecked.defaultof<'T>
diff --git a/tests/Cosmos.Tests/BuilderUnitTests.fs b/tests/Cosmos.Tests/BuilderUnitTests.fs
new file mode 100644
index 0000000..2743bf2
--- /dev/null
+++ b/tests/Cosmos.Tests/BuilderUnitTests.fs
@@ -0,0 +1,233 @@
+namespace FSharp.Azure.Cosmos.Tests
+
+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.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,
+ "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.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.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,
+ "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.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.")
+ 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.")
+
+ []
+ 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/CosmosAssert.fs b/tests/Cosmos.Tests/CosmosAssert.fs
new file mode 100644
index 0000000..96b1edd
--- /dev/null
+++ b/tests/Cosmos.Tests/CosmosAssert.fs
@@ -0,0 +1,95 @@
+namespace FSharp.Azure.Cosmos.Tests.Integration
+
+open System
+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 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 (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
+
+ static member WantOk<'T> (result : CreateResult<'T>, [] message) =
+ match result with
+ | CreateResult.Ok ok -> ok
+ | _ ->
+ 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
+
+ static member WantOk<'T> (result : ReadResult<'T>, [] message) =
+ match result with
+ | ReadResult.Ok ok -> ok
+ | _ ->
+ 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
+
+ static member WantOk<'T> (result : ReplaceResult<'T>, [] message) =
+ match result with
+ | ReplaceResult.Ok ok -> ok
+ | _ ->
+ 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
+
+ static member WantOk<'T> (result : DeleteResult<'T>, [] message) =
+ match result with
+ | DeleteResult.Ok ok -> ok
+ | _ ->
+ 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
+
+ static member WantNotFound<'T> (result : ReadResult<'T>, [] message) =
+ match result with
+ | ReadResult.NotFound response -> response
+ | _ ->
+ Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected ReadResult.NotFound but got {result}.")
+ 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 (CosmosAssert.GetMessageOrDefault message $"Expected DeleteResult.NotFound but got {result}.")
+ 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 (CosmosAssert.GetMessageOrDefault message $"Expected CreateResult.IdAlreadyExists but got {result}.")
+
+ static member IsConflict (result : CreateResult<'T>, [] message) =
+ CosmosAssert.WantConflict (result, message) |> ignore
diff --git a/tests/Cosmos.Tests/CreateOperationTests.fs b/tests/Cosmos.Tests/CreateOperationTests.fs
new file mode 100644
index 0000000..143541b
--- /dev/null
+++ b/tests/Cosmos.Tests/CreateOperationTests.fs
@@ -0,0 +1,61 @@
+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 ()
+
+ []
+ 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..ed22f8e
--- /dev/null
+++ b/tests/Cosmos.Tests/DeleteOperationTests.fs
@@ -0,0 +1,52 @@
+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 ()
+
+ []
+ 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 9588d7d..e186657 100644
--- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj
+++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj
@@ -17,7 +17,21 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs
new file mode 100644
index 0000000..31d1c10
--- /dev/null
+++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs
@@ -0,0 +1,165 @@
+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
+
+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
+ |> int64
+ |> abs
+
+ $"{ctx.TestName}_{dataHash}"
+
+[]
+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 () = 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, HttpClientFactory = Func createHttpClient)
+ )
+ 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 _.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
+
+ interface IAsyncDisposable with
+ member this.DisposeAsync () =
+ task {
+ do! this.CleanupAsync (CancellationToken.None)
+ client.Dispose ()
+ }
+ |> ValueTask
+
+[]
+type IntegrationTestBase<'DatabaseTestApplicationFactory when 'DatabaseTestApplicationFactory :> DatabaseTestApplicationFactory>
+ ()
+ =
+ inherit TestBase ()
+
+ 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
+
+ abstract CreateApplication : TestContext -> 'DatabaseTestApplicationFactory
+
+ []
+ 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
+ }
+
+type IntegrationTestBase () =
+ inherit IntegrationTestBase ()
+
+ override _.CreateApplication context = DatabaseTestApplicationFactory (context)
diff --git a/tests/Cosmos.Tests/IterationExtensionsTests.fs b/tests/Cosmos.Tests/IterationExtensionsTests.fs
new file mode 100644
index 0000000..0dd7316
--- /dev/null
+++ b/tests/Cosmos.Tests/IterationExtensionsTests.fs
@@ -0,0 +1,61 @@
+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 ()
+
+ override _.CreateApplication context = MultipleItemsScenario (context)
+
+ []
+ member this.``FeedIterator AsAsyncEnumerable iterates seeded 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 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
+ |> Seq.filter (fun item -> expectedIds.Contains item.id)
+ |> Seq.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, 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 (
+ requestOptions = QueryRequestOptions (PartitionKey = PartitionKey "integration")
+ )
+
+ let expectedIds = set [ firstItem.id; secondItem.id ]
+ let! iteratedItems =
+ queryable.AsAsyncEnumerable (this.CancellationToken)
+ |> TaskSeq.toListAsync
+ let foundCount =
+ iteratedItems
+ |> Seq.filter (fun item -> expectedIds.Contains item.id)
+ |> Seq.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
new file mode 100644
index 0000000..3478351
--- /dev/null
+++ b/tests/Cosmos.Tests/OperationTestInfrastructure.fs
@@ -0,0 +1,47 @@
+namespace FSharp.Azure.Cosmos.Tests.Integration
+
+open System
+open System.Threading.Tasks
+open FSharp.Azure.Cosmos
+open Microsoft.Azure.Cosmos
+
+[]
+type TestItem = { id : string; partitionKey : string; name : string; quantity : int }
+
+[]
+type OperationTestBase<'DatabaseTestApplicationFactory when 'DatabaseTestApplicationFactory :> DatabaseTestApplicationFactory> ()
+ =
+ inherit IntegrationTestBase<'DatabaseTestApplicationFactory> ()
+
+ let containerId = "operation-tests"
+
+ member this.GetContainer () : Task = task {
+ return! this.Application.GetOrCreateContainerAsync (containerId, "/partitionKey", this.CancellationToken)
+ }
+
+ member internal this.NewItem (suffix : string) : TestItem = {
+ id = $"{this.TestContext.TestName}-{suffix}"
+ partitionKey = "integration"
+ 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}'.")
+ }
+
+[]
+type OperationTestBase () =
+ inherit OperationTestBase ()
+
+ override _.CreateApplication context = DatabaseTestApplicationFactory (context)
diff --git a/tests/Cosmos.Tests/PatchOperationTests.fs b/tests/Cosmos.Tests/PatchOperationTests.fs
new file mode 100644
index 0000000..f7d907a
--- /dev/null
+++ b/tests/Cosmos.Tests/PatchOperationTests.fs
@@ -0,0 +1,102 @@
+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 ()
+
+ []
+ 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/ReadExtensionsTests.fs b/tests/Cosmos.Tests/ReadExtensionsTests.fs
new file mode 100644
index 0000000..829ac4a
--- /dev/null
+++ b/tests/Cosmos.Tests/ReadExtensionsTests.fs
@@ -0,0 +1,164 @@
+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 ReadExtensionsIntegrationTests () =
+ 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! notDeletedWithUnderscoreFieldName = container.IsNotDeletedAsync "_deletedAt" secondItem.id
+
+ Assert.IsTrue (
+ notDeletedWithUnderscoreFieldName,
+ "IsNotDeletedAsync should support deleted field names starting with underscore."
+ )
+
+ let! notDeletedWithDigit = container.IsNotDeletedAsync "deletedAt1" secondItem.id
+
+ Assert.IsTrue (
+ notDeletedWithDigit,
+ "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 {
+ 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.")
+ }
+
+ []
+ 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) =
+ Func (fun () -> task {
+ let! _ = container.IsNotDeletedAsync deletedFieldName testItem.id
+ return ()
+ })
+
+ let! _ =
+ Assert.ThrowsExactlyAsync (
+ Func (fun () -> task {
+ let! _ = container.IsNotDeletedAsync Unchecked.defaultof testItem.id
+ return ()
+ }),
+ "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 " ",
+ "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 "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 ()
+ }
diff --git a/tests/Cosmos.Tests/ReadManyOperationTests.fs b/tests/Cosmos.Tests/ReadManyOperationTests.fs
new file mode 100644
index 0000000..7646a0e
--- /dev/null
+++ b/tests/Cosmos.Tests/ReadManyOperationTests.fs
@@ -0,0 +1,79 @@
+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 MultipleItemsScenario (testContext : TestContext) as this =
+ inherit DatabaseTestApplicationFactory (testContext)
+
+ let containerId = "operation-tests"
+
+ let firstSeededItem : TestItem = {
+ id = $"{testContext.TestName}-readmany-1"
+ partitionKey = "integration"
+ name = "item-readmany-1"
+ quantity = 1
+ }
+
+ let secondSeededItem : TestItem = {
+ id = $"{testContext.TestName}-readmany-2"
+ partitionKey = "integration"
+ name = "item-readmany-2"
+ quantity = 2
+ }
+
+ 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}'.")
+ }
+
+[]
+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 (
+ 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..a758825
--- /dev/null
+++ b/tests/Cosmos.Tests/ReadOperationTests.fs
@@ -0,0 +1,75 @@
+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 SingleItemScenario (testContext : TestContext) as this =
+ inherit DatabaseTestApplicationFactory (testContext)
+
+ 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 seededItem
+ partitionKey seededItem.partitionKey
+ },
+ cancellationToken
+ )
+
+ 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 (
+ 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..aafc6df
--- /dev/null
+++ b/tests/Cosmos.Tests/ReplaceOperationTests.fs
@@ -0,0 +1,150 @@
+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 ()
+
+ []
+ 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.")
+ }
+
+ []
+ 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/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/Tests.fs b/tests/Cosmos.Tests/Tests.fs
deleted file mode 100644
index 7afcb8b..0000000
--- a/tests/Cosmos.Tests/Tests.fs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Tests
-
-open System
-open Microsoft.VisualStudio.TestTools.UnitTesting
-
-[]
-type TestClass () =
-
- []
- member this.TestMethodPassing () = Assert.IsTrue (true)
diff --git a/tests/Cosmos.Tests/UpsertOperationTests.fs b/tests/Cosmos.Tests/UpsertOperationTests.fs
new file mode 100644
index 0000000..791f889
--- /dev/null
+++ b/tests/Cosmos.Tests/UpsertOperationTests.fs
@@ -0,0 +1,163 @@
+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 ()
+
+ []
+ 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}.")
+ }
+
+ []
+ 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}.")
+ }