diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 1810ef9..fddb78c 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -6,6 +6,19 @@
"forwardPorts": [
0
],
+ // Share the host's network namespace so the container can reach the Cosmos DB Emulator
+ // that runs as a sibling container on the host, bound to the host's 127.0.0.1:8081.
+ "runArgs": [
+ "--network=host"
+ ],
+ // Runs on the host before the container is created. On GitHub Actions it starts the
+ // Cosmos DB Emulator and waits until it is ready; elsewhere it does nothing.
+ "initializeCommand": [
+ "pwsh",
+ "-NoProfile",
+ "-File",
+ "${localWorkspaceFolder}/.devcontainer/initialize-cosmos-emulator.ps1"
+ ],
"features": {
// https://github.com/devcontainers/features/blob/main/src/common-utils/README.md
"ghcr.io/devcontainers/features/common-utils:2": {
@@ -19,8 +32,8 @@
},
// https://github.com/devcontainers/features/blob/main/src/github-cli/README.md
"ghcr.io/devcontainers/features/github-cli:1": {},
- // https://github.com/devcontainers-contrib/features/blob/main/src/starship/README.md
- "ghcr.io/devcontainers-contrib/features/starship:1": {},
+ // https://github.com/devcontainers-extra/features/blob/main/src/starship/README.md
+ "ghcr.io/devcontainers-extra/features/starship:1": {},
// https://github.com/devcontainers/features/blob/main/src/dotnet/README.md
"ghcr.io/devcontainers/features/dotnet:2": {
"version": "10.0",
@@ -30,7 +43,7 @@
"overrideFeatureInstallOrder": [
"ghcr.io/devcontainers/features/common-utils",
"ghcr.io/devcontainers/features/github-cli",
- "ghcr.io/devcontainers-contrib/features/starship",
+ "ghcr.io/devcontainers-extra/features/starship",
"ghcr.io/devcontainers/features/dotnet"
],
"customizations": {
diff --git a/.devcontainer/initialize-cosmos-emulator.ps1 b/.devcontainer/initialize-cosmos-emulator.ps1
new file mode 100644
index 0000000..2d483e7
--- /dev/null
+++ b/.devcontainer/initialize-cosmos-emulator.ps1
@@ -0,0 +1,52 @@
+#!/usr/bin/env pwsh
+# Host-side devcontainer initializeCommand.
+#
+# On GitHub Actions, start the Cosmos DB Emulator container on the runner and wait until it is ready,
+# so the dev container (which shares the host network) can run the integration tests against
+# 127.0.0.1:8081. Local developers manage their own emulator, so outside GitHub Actions this is a no-op.
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+if ($env:GITHUB_ACTIONS -ne 'true') {
+ exit 0
+}
+
+$containerName = 'cosmosdb'
+
+# The dev container CLI can run initializeCommand more than once, so only create the container once.
+$existing = docker ps --all --filter "name=^$containerName$" --format '{{.Names}}'
+if ($existing -contains $containerName) {
+ Write-Host 'Cosmos DB Emulator container already exists.'
+ docker start $containerName | Out-Null
+}
+else {
+ # Same settings as .github/scripts/linux/start-cosmos-emulator.sh, which the main Linux CI job uses.
+ docker run -d --name $containerName `
+ -p 8081:8081 -p 8080:8080 -p 1234:1234 `
+ -e PROTOCOL=https `
+ mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview | Out-Null
+}
+
+if ($LASTEXITCODE -ne 0) {
+ throw "docker exited with code $LASTEXITCODE."
+}
+
+$maxAttempts = 120
+for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
+ try {
+ $response = Invoke-WebRequest -Uri 'http://127.0.0.1:8080/ready' -SkipHttpErrorCheck -TimeoutSec 5
+ if ($response.StatusCode -eq 200) {
+ Write-Host 'Cosmos DB Emulator is ready.'
+ exit 0
+ }
+ }
+ catch {
+ # The readiness endpoint is not listening yet.
+ }
+
+ Write-Host "Cosmos DB Emulator is not ready yet (attempt $attempt/$maxAttempts)."
+ Start-Sleep -Seconds 5
+}
+
+docker logs --tail 50 $containerName
+throw 'Cosmos DB Emulator failed to become ready in time.'
diff --git a/.github/scripts/windows/start-cosmos-emulator.ps1 b/.github/scripts/windows/start-cosmos-emulator.ps1
index 1e01336..ab2b2f9 100644
--- a/.github/scripts/windows/start-cosmos-emulator.ps1
+++ b/.github/scripts/windows/start-cosmos-emulator.ps1
@@ -4,11 +4,15 @@ Start-Process -FilePath $emulatorPath -ArgumentList '/NoUI /NoExplorer /AllowNet
$maxAttempts = 60
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
- $response = Invoke-WebRequest -Uri 'https://127.0.0.1:8081/' -SkipCertificateCheck -Method Get -TimeoutSec 5
+ # An unauthenticated request to the emulator root returns 401 once it is up.
+ # PowerShell 7 throws for 4xx responses unless -SkipHttpErrorCheck is set.
+ $response = Invoke-WebRequest -Uri 'https://127.0.0.1:8081/' -SkipCertificateCheck -SkipHttpErrorCheck -Method Get -TimeoutSec 5
if ($response.StatusCode -in 200, 401) {
- Write-Host 'Cosmos DB Emulator is ready on Windows.'
+ Write-Host "Cosmos DB Emulator is ready on Windows (status: $($response.StatusCode))."
exit 0
}
+
+ Write-Host "Cosmos DB Emulator returned status $($response.StatusCode) (attempt $attempt/$maxAttempts)."
}
catch {
Write-Host "Cosmos DB Emulator is not ready yet (attempt $attempt/$maxAttempts)."
@@ -17,4 +21,5 @@ for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Start-Sleep -Seconds 5
}
-throw 'Cosmos DB Emulator failed to become ready on Windows.'
+# Warn instead of failing: the Windows job runs the build-only target, so it does not need the emulator.
+Write-Warning 'Cosmos DB Emulator failed to become ready on Windows. Continuing because Windows job runs build-only target.'
diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs
index 5f0e742..e1c6fdd 100644
--- a/src/Cosmos/Cosmos.fs
+++ b/src/Cosmos/Cosmos.fs
@@ -8,6 +8,48 @@ open System.Threading.Tasks
open FSharp.Control
open Microsoft.Azure.Cosmos
+///
+/// Helpers for validating Cosmos DB item field names used in dynamically constructed queries.
+///
+module CosmosName =
+
+ let private isAsciiLetter c = ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')
+ let private isAsciiDigit c = '0' <= c && c <= '9'
+
+ ///
+ /// Validates that is a syntactically valid Cosmos DB item field name:
+ /// non-null, non-empty, starting with a letter or underscore, and containing only letters, digits,
+ /// or underscores.
+ ///
+ /// Name of the caller's parameter to report in a thrown exception.
+ /// Field name to validate.
+ /// Thrown when is null.
+ ///
+ /// Thrown when does not start with a letter or underscore,
+ /// or contains characters other than letters, digits, or underscores.
+ ///
+ []
+ let validateField (paramName : string) (fieldName : string) =
+ if obj.ReferenceEquals (fieldName, null) then
+ nullArg paramName
+
+ let isValidFieldName =
+ if String.IsNullOrWhiteSpace fieldName then
+ false
+ else
+ let firstCharacter = fieldName[0]
+ let hasValidStart = firstCharacter = '_' || isAsciiLetter firstCharacter
+ let hasValidBody =
+ fieldName
+ |> Seq.forall (fun c -> c = '_' || isAsciiLetter c || isAsciiDigit c)
+
+ hasValidStart && hasValidBody
+
+ if not isValidFieldName then
+ invalidArg
+ paramName
+ "Field name must start with a letter or underscore and contain only letters, digits, or underscores."
+
module internal RequestOptions =
let internal createOrUpdate setter requestOptions =
@@ -70,6 +112,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 +123,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,10 +144,19 @@ 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))
- options.PostTriggers <- [| yield! options.PostTriggers; yield! triggers |]
+ options.PostTriggers <- [|
+ if not <| isNull options.PostTriggers then
+ yield! options.PostTriggers
+ yield! triggers
+ |]
let internal countQuery = QueryDefinition ("SELECT VALUE COUNT(1) FROM c")
let internal existsQuery = QueryDefinition ("SELECT VALUE COUNT(1) FROM item WHERE item.id = @Id")
@@ -209,27 +269,42 @@ module Operations =
container.ExistsAsync (id, QueryRequestOptions (PartitionKey = partitionKey), cancellationToken)
///
- /// Checks if an item with specified Id exists in the container partition with specified key.
+ /// Checks whether an item with the specified Id exists and is not marked as deleted.
+ ///
+ /// The item is treated as not deleted when the field is absent,
+ /// null, or false. Any other value, such as true or a deletion timestamp,
+ /// marks the item as deleted.
+ ///
///
+ /// Name of the item field that marks the item as deleted.
/// Item Id
- /// Partition key
+ /// Query request options, for example to scope the query to a partition key.
/// Cancellation token
+ /// true when the item exists and is not marked as deleted; otherwise false.
+ /// 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)
=
+ CosmosName.validateField (nameof deletedFieldName) deletedFieldName
+
task {
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}) OR item.{deletedFieldName} = false)"""
)
.WithParameter ("@Id", id)
let! count =
container.GetItemQueryIterator (
query,
- requestOptions = getRequestOptionsWithMaxItemCount1 requiestOptions
+ requestOptions = getRequestOptionsWithMaxItemCount1 requestOptions
)
|> CancellableTaskSeq.ofFeedIterator cancellationToken
|> TaskSeq.tryHead
diff --git a/src/Cosmos/IterationExtensions.fs b/src/Cosmos/IterationExtensions.fs
index 48a1f20..2423478 100644
--- a/src/Cosmos/IterationExtensions.fs
+++ b/src/Cosmos/IterationExtensions.fs
@@ -1,10 +1,63 @@
-namespace Microsoft.Azure.Cosmos
+namespace Microsoft.Azure.Cosmos
+open System.Collections.Generic
open System.Runtime.CompilerServices
open System.Runtime.InteropServices
open System.Threading
+open System.Threading.Tasks
open Microsoft.Azure.Cosmos
-open FSharp.Control
+
+/// Enumerates the items of every page of a .
+///
+/// Implemented by hand rather than with a taskSeq { } computation expression: taskSeq has no
+/// dynamic implementation, so it throws NotImplementedException whenever the compiler does not turn it
+/// into a static state machine, which is the case for assemblies built without optimizations (Debug).
+///
+[]
+type internal FeedIteratorAsyncEnumerator<'T> (iterator : FeedIterator<'T>, cancellationToken : CancellationToken) =
+
+ let mutable page : IEnumerator<'T> voption = ValueNone
+ let mutable current = Unchecked.defaultof<'T>
+
+ let disposePage () =
+ page |> ValueOption.iter _.Dispose()
+ page <- ValueNone
+
+ interface IAsyncEnumerator<'T> with
+
+ member _.Current = current
+
+ member _.MoveNextAsync () =
+ let moveNext = task {
+ let mutable found = false
+ let mutable exhausted = false
+
+ while not (found || exhausted) do
+ match page with
+ | ValueSome items when items.MoveNext () ->
+ cancellationToken.ThrowIfCancellationRequested ()
+ current <- items.Current
+ found <- true
+ | _ when iterator.HasMoreResults ->
+ disposePage ()
+ let! response = iterator.ReadNextAsync cancellationToken
+ page <- ValueSome (response.GetEnumerator ())
+ | _ -> exhausted <- true
+
+ return found
+ }
+
+ ValueTask (moveNext)
+
+ member _.DisposeAsync () =
+ disposePage ()
+ ValueTask.CompletedTask
+
+[]
+type internal FeedIteratorAsyncEnumerable<'T> (iterator : FeedIterator<'T>, cancellationToken : CancellationToken) =
+
+ interface IAsyncEnumerable<'T> with
+ member _.GetAsyncEnumerator (_ : CancellationToken) = new FeedIteratorAsyncEnumerator<'T> (iterator, cancellationToken)
[]
module FeedIteratorExtensions =
@@ -13,14 +66,8 @@ module FeedIteratorExtensions =
type FeedIterator<'T> with
/// Converts the iterator to an async sequence of items.
- member iterator.AsAsyncEnumerable<'T> ([] cancellationToken : CancellationToken) = taskSeq {
- while iterator.HasMoreResults do
- let! page = iterator.ReadNextAsync (cancellationToken)
-
- for item in page do
- cancellationToken.ThrowIfCancellationRequested ()
- yield item
- }
+ member iterator.AsAsyncEnumerable<'T> ([] cancellationToken : CancellationToken) =
+ FeedIteratorAsyncEnumerable (iterator, cancellationToken) :> IAsyncEnumerable<_>
open System.Linq
open Microsoft.Azure.Cosmos
diff --git a/src/Cosmos/Read.fs b/src/Cosmos/Read.fs
index 20d6861..677acc7 100644
--- a/src/Cosmos/Read.fs
+++ b/src/Cosmos/Read.fs
@@ -145,19 +145,26 @@ type Microsoft.Azure.Cosmos.Container with
///
/// Read operation
/// Cancellation token
- member container.ExecuteAsync<'T> (operation : ReadOperation<'T>, [] cancellationToken : CancellationToken) =
- let successFn result : ReadResult<'T> =
- if Object.Equals (result, Unchecked.defaultof<'T>) then
- ReadResult.NotModified
- else
- ReadResult.Ok result
-
- container.ExecuteAsync<'T, ReadResult<'T>> (
- operation,
- successFn,
- toReadResult ReadResult.IncompatibleConsistencyLevel ReadResult.NotFound,
- cancellationToken
- )
+ member container.ExecuteAsync<'T>
+ (operation : ReadOperation<'T>, [] cancellationToken : CancellationToken)
+ : Task>>
+ =
+ task {
+ try
+ let! response = container.PlainExecuteAsync (operation, cancellationToken)
+
+ // A matching If-None-Match can come back as a successful 304 response...
+ if response.StatusCode = HttpStatusCode.NotModified then
+ return CosmosResponse.fromItemResponse (fun _ -> ReadResult.NotModified) response
+ else
+ return CosmosResponse.fromItemResponse ReadResult.Ok response
+ with
+ // ...or, depending on the SDK transport and emulator, as a thrown 304 CosmosException.
+ | CosmosException ex when ex.StatusCode = HttpStatusCode.NotModified ->
+ return CosmosResponse.fromException (fun _ -> ReadResult.NotModified) ex
+ | HandleException ex ->
+ return CosmosResponse.fromException (toReadResult ReadResult.IncompatibleConsistencyLevel ReadResult.NotFound) ex
+ }
///
/// Executes a read operation and returns .
diff --git a/src/Cosmos/ReadMany.fs b/src/Cosmos/ReadMany.fs
index 9de9d72..b55bb4c 100644
--- a/src/Cosmos/ReadMany.fs
+++ b/src/Cosmos/ReadMany.fs
@@ -143,16 +143,26 @@ type Microsoft.Azure.Cosmos.Container with
///
/// Read operation
/// Cancellation token
- member container.ExecuteAsync<'T> (operation : ReadManyOperation<'T>, [] cancellationToken : CancellationToken) =
- let successFn result : ReadManyResult> =
- if Object.Equals (result, Unchecked.defaultof<'T>) then
- ReadManyResult.NotModified
- else
- ReadManyResult.Ok result
-
- container.ExecuteAsync<'T, ReadManyResult>> (
- operation,
- successFn,
- toReadResult ReadManyResult.IncompatibleConsistencyLevel ReadManyResult.NotFound,
- cancellationToken
- )
+ member container.ExecuteAsync<'T>
+ (operation : ReadManyOperation<'T>, [] cancellationToken : CancellationToken)
+ : Task>>>
+ =
+ task {
+ try
+ let! response = container.PlainExecuteAsync (operation, cancellationToken)
+
+ // A matching If-None-Match can come back as a successful 304 feed response...
+ if response.StatusCode = HttpStatusCode.NotModified then
+ return CosmosResponse.fromFeedResponse (fun _ -> ReadManyResult.NotModified) response
+ else
+ return CosmosResponse.fromFeedResponse ReadManyResult.Ok response
+ with
+ // ...or, depending on the SDK transport and emulator, as a thrown 304 CosmosException.
+ | CosmosException ex when ex.StatusCode = HttpStatusCode.NotModified ->
+ return CosmosResponse.fromException (fun _ -> ReadManyResult.NotModified) ex
+ | HandleException ex ->
+ return
+ CosmosResponse.fromException
+ (toReadResult ReadManyResult.IncompatibleConsistencyLevel ReadManyResult.NotFound)
+ ex
+ }
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..6174f2d
--- /dev/null
+++ b/tests/Cosmos.Tests/BuilderUnitTests.fs
@@ -0,0 +1,534 @@
+namespace FSharp.Azure.Cosmos.Tests
+
+open System
+open System.Threading.Tasks
+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"
+ consistencyLevel (Nullable ConsistencyLevel.Session)
+ indexingDirective (Nullable IndexingDirective.Include)
+ preTrigger "pre-1"
+ preTriggers [ "pre-2"; "pre-3" ]
+ postTrigger "post-1"
+ postTriggers [ "post-2"; "post-3" ]
+ }
+
+ let createAndReadOperation = createAndRead {
+ item createItem
+ partitionKey createItem.partitionKey
+ sessionToken "create-and-read-session"
+ }
+
+ Assert.IsValueSome (createOperation.PartitionKey, "Create builder should set partition key.")
+ Assert.AreEqual (
+ "create-session",
+ createOperation.RequestOptions.SessionToken,
+ "Create builder should set session token."
+ )
+ Assert.AreEqual (
+ Nullable ConsistencyLevel.Session,
+ createOperation.RequestOptions.ConsistencyLevel,
+ "Create builder should set consistency level."
+ )
+ Assert.AreEqual (
+ Nullable IndexingDirective.Include,
+ createOperation.RequestOptions.IndexingDirective,
+ "Create builder should set indexing directive."
+ )
+ CollectionAssert.AreEqual (
+ [| "pre-1"; "pre-2"; "pre-3" |],
+ Array.ofSeq createOperation.RequestOptions.PreTriggers,
+ "Create builder should accumulate pre-triggers in call order."
+ )
+ CollectionAssert.AreEqual (
+ [| "post-1"; "post-2"; "post-3" |],
+ Array.ofSeq createOperation.RequestOptions.PostTriggers,
+ "Create builder should accumulate post-triggers in call order."
+ )
+ Assert.IsFalse (
+ createOperation.RequestOptions.EnableContentResponseOnWrite,
+ "Create builder should disable content response."
+ )
+ Assert.IsTrue (
+ createAndReadOperation.RequestOptions.EnableContentResponseOnWrite,
+ "CreateAndRead builder should enable content response."
+ )
+
+ []
+ member _.``Create requestOptions override replaces options but re-applies content response mode`` () =
+ let customOptions =
+ ItemRequestOptions (SessionToken = "custom-session", EnableContentResponseOnWrite = true)
+
+ let operation = create {
+ item { id = "create-id"; partitionKey = "pk"; value = 1 }
+ requestOptions customOptions
+ }
+
+ Assert.AreSame (customOptions, operation.RequestOptions, "Create requestOptions should replace the operation's options.")
+ Assert.AreEqual (
+ "custom-session",
+ operation.RequestOptions.SessionToken,
+ "Create requestOptions should preserve unrelated properties of the supplied options."
+ )
+ Assert.IsFalse (
+ operation.RequestOptions.EnableContentResponseOnWrite,
+ "Create requestOptions should force content response mode back to the builder's own mode."
+ )
+
+ []
+ member _.``Read builder configures id and partition key and request options`` () =
+ let operation = read {
+ id "read-id"
+ partitionKey "pk"
+ eTag "etag-value"
+ sessionToken "read-session"
+ consistencyLevel (Nullable ConsistencyLevel.Eventual)
+ indexingDirective (Nullable IndexingDirective.Exclude)
+ }
+
+ Assert.AreEqual ("read-id", operation.Id, "Read builder should set id.")
+ Assert.IsNotNull (operation.RequestOptions, "Read builder should initialize request options when needed.")
+ Assert.AreEqual ("etag-value", operation.RequestOptions.IfNoneMatchEtag, "Read builder should set eTag option.")
+ Assert.AreEqual ("read-session", operation.RequestOptions.SessionToken, "Read builder should set session token.")
+ Assert.AreEqual (
+ Nullable ConsistencyLevel.Eventual,
+ operation.RequestOptions.ConsistencyLevel,
+ "Read builder should set consistency level."
+ )
+ Assert.AreEqual (
+ Nullable IndexingDirective.Exclude,
+ operation.RequestOptions.IndexingDirective,
+ "Read builder should set indexing directive."
+ )
+
+ []
+ member _.``Read builder initializes request options fresh for consistencyLevel as the first option`` () =
+ let operation = read {
+ id "read-id-2"
+ partitionKey "pk"
+ consistencyLevel (Nullable ConsistencyLevel.Strong)
+ }
+
+ Assert.IsNotNull (
+ operation.RequestOptions,
+ "Read builder should initialize request options when consistencyLevel is the first option set."
+ )
+ Assert.AreEqual (
+ Nullable ConsistencyLevel.Strong,
+ operation.RequestOptions.ConsistencyLevel,
+ "Read builder should set consistency level when initializing fresh options."
+ )
+
+ []
+ member _.``ReadMany builder collects item tuples and request options`` () =
+ let operation = readMany {
+ item "item-1" "pk"
+ item "item-2" (PartitionKey "pk")
+ items [ struct ("item-3", PartitionKey "pk") ]
+ items [ struct ("item-4", "pk") ]
+ sessionToken "readmany-session"
+ consistencyLevel (Nullable ConsistencyLevel.Session)
+ }
+
+ Assert.HasCount (4, operation.Items, "ReadMany builder should collect items from both item and items calls.")
+ Assert.IsNotNull (operation.RequestOptions, "ReadMany builder should create request options when needed.")
+ Assert.AreEqual ("readmany-session", operation.RequestOptions.SessionToken, "ReadMany builder should set session token.")
+ Assert.AreEqual (
+ Nullable ConsistencyLevel.Session,
+ operation.RequestOptions.ConsistencyLevel,
+ "ReadMany builder should set consistency level."
+ )
+
+ []
+ 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"
+ consistencyLevel (Nullable ConsistencyLevel.BoundedStaleness)
+ indexingDirective (Nullable IndexingDirective.Include)
+ preTrigger "pre-1"
+ preTriggers [ "pre-2" ]
+ postTrigger "post-1"
+ postTriggers [ "post-2" ]
+ }
+
+ let replaceAndReadOperation = replaceAndRead {
+ id replaceItem.id
+ item replaceItem
+ partitionKey replaceItem.partitionKey
+ }
+
+ Assert.AreEqual (replaceItem.id, replaceOperation.Id, "Replace builder should set id.")
+ Assert.AreEqual ("replace-etag", replaceOperation.RequestOptions.IfMatchEtag, "Replace builder should set eTag.")
+ Assert.AreEqual (
+ Nullable ConsistencyLevel.BoundedStaleness,
+ replaceOperation.RequestOptions.ConsistencyLevel,
+ "Replace builder should set consistency level."
+ )
+ Assert.AreEqual (
+ Nullable IndexingDirective.Include,
+ replaceOperation.RequestOptions.IndexingDirective,
+ "Replace builder should set indexing directive."
+ )
+ CollectionAssert.AreEqual (
+ [| "pre-1"; "pre-2" |],
+ Array.ofSeq replaceOperation.RequestOptions.PreTriggers,
+ "Replace builder should accumulate pre-triggers in call order."
+ )
+ CollectionAssert.AreEqual (
+ [| "post-1"; "post-2" |],
+ Array.ofSeq replaceOperation.RequestOptions.PostTriggers,
+ "Replace builder should accumulate post-triggers in call order."
+ )
+ 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`` () : Task = task {
+ 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.StartAsTask
+
+ Assert.AreEqual ("replace-concurrent-id", replaceConcurrentlyOperation.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"
+ consistencyLevel (Nullable ConsistencyLevel.ConsistentPrefix)
+ indexingDirective (Nullable IndexingDirective.Include)
+ preTrigger "pre-1"
+ preTriggers [ "pre-2" ]
+ postTrigger "post-1"
+ postTriggers [ "post-2" ]
+ }
+
+ let upsertAndReadOperation = upsertAndRead {
+ item upsertItem
+ partitionKey upsertItem.partitionKey
+ }
+
+ Assert.IsValueSome (upsertOperation.PartitionKey, "Upsert builder should set partition key.")
+ Assert.AreEqual ("upsert-etag", upsertOperation.RequestOptions.IfMatchEtag, "Upsert builder should set eTag.")
+ Assert.AreEqual (
+ Nullable ConsistencyLevel.ConsistentPrefix,
+ upsertOperation.RequestOptions.ConsistencyLevel,
+ "Upsert builder should set consistency level."
+ )
+ Assert.AreEqual (
+ Nullable IndexingDirective.Include,
+ upsertOperation.RequestOptions.IndexingDirective,
+ "Upsert builder should set indexing directive."
+ )
+ CollectionAssert.AreEqual (
+ [| "pre-1"; "pre-2" |],
+ Array.ofSeq upsertOperation.RequestOptions.PreTriggers,
+ "Upsert builder should accumulate pre-triggers in call order."
+ )
+ CollectionAssert.AreEqual (
+ [| "post-1"; "post-2" |],
+ Array.ofSeq upsertOperation.RequestOptions.PostTriggers,
+ "Upsert builder should accumulate post-triggers in call order."
+ )
+ 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`` () : Task = task {
+ 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.StartAsTask
+
+ Assert.AreEqual ("upsert-concurrent-id", upsertConcurrentlyOperation.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))
+ operations [ PatchOperation.Set ("/name", "patched"); PatchOperation.Remove "/unused" ]
+ filterPredicate "FROM c WHERE c.partitionKey = 'pk'"
+ eTag "patch-etag"
+ consistencyLevel (Nullable ConsistencyLevel.Eventual)
+ preTrigger "pre-1"
+ preTriggers [ "pre-2" ]
+ postTrigger "post-1"
+ postTriggers [ "post-2" ]
+ }
+
+ let patchAndReadOperation = patchAndRead {
+ id "patch-and-read-id"
+ partitionKey "pk"
+ operation (PatchOperation.Replace ("/value", 5))
+ }
+
+ Assert.AreEqual ("patch-id", patchOperation.Id, "Patch builder should set id.")
+ Assert.HasCount (
+ 3,
+ patchOperation.Operations,
+ "Patch builder should collect operations from both operation and operations calls."
+ )
+ Assert.AreEqual (
+ "FROM c WHERE c.partitionKey = 'pk'",
+ patchOperation.RequestOptions.FilterPredicate,
+ "Patch builder should set filter predicate."
+ )
+ Assert.AreEqual ("patch-etag", patchOperation.RequestOptions.IfMatchEtag, "Patch builder should set eTag.")
+ Assert.AreEqual (
+ Nullable ConsistencyLevel.Eventual,
+ patchOperation.RequestOptions.ConsistencyLevel,
+ "Patch builder should set consistency level."
+ )
+ CollectionAssert.AreEqual (
+ [| "pre-1"; "pre-2" |],
+ Array.ofSeq patchOperation.RequestOptions.PreTriggers,
+ "Patch builder should accumulate pre-triggers in call order."
+ )
+ CollectionAssert.AreEqual (
+ [| "post-1"; "post-2" |],
+ Array.ofSeq patchOperation.RequestOptions.PostTriggers,
+ "Patch builder should accumulate post-triggers in call order."
+ )
+ Assert.IsFalse (
+ patchOperation.RequestOptions.EnableContentResponseOnWrite,
+ "Patch builder should disable content response."
+ )
+ Assert.IsTrue (
+ patchAndReadOperation.RequestOptions.EnableContentResponseOnWrite,
+ "PatchAndRead builder should enable content response."
+ )
+
+ []
+ member _.``Patch requestOptions override re-applies the operation's own content response mode`` () =
+ let customOptions =
+ PatchItemRequestOptions (FilterPredicate = "FROM c", EnableContentResponseOnWrite = false)
+
+ let operation = patchAndRead {
+ id "patch-id"
+ partitionKey "pk"
+ operation (PatchOperation.Replace ("/value", 2))
+ requestOptions customOptions
+ }
+
+ Assert.AreSame (customOptions, operation.RequestOptions, "Patch requestOptions should replace the operation's options.")
+ Assert.IsTrue (
+ operation.RequestOptions.EnableContentResponseOnWrite,
+ "Patch requestOptions should re-apply the current state's content response mode (true for patchAndRead), overriding the supplied options' own value."
+ )
+
+ []
+ 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.AreEqual ("delete-id", operation.Id, "Delete builder should set id.")
+ let options =
+ Assert.WantValueSome (operation.RequestOptions, "Delete builder should initialize request options.")
+ Assert.AreEqual ("delete-etag", options.IfNoneMatchEtag, "Delete builder should set eTag.")
+ Assert.AreEqual ("delete-session", options.SessionToken, "Delete builder should set session token.")
+
+ []
+ member _.``Delete builder initializes request options fresh for consistencyLevel as the first option`` () =
+ let operation = delete {
+ id "delete-id-2"
+ partitionKey "pk"
+ consistencyLevel (Nullable ConsistencyLevel.Strong)
+ enableContentResponseOnWrite true
+ indexingDirective (Nullable IndexingDirective.Include)
+ preTrigger "pre-1"
+ preTriggers [ "pre-2" ]
+ postTrigger "post-1"
+ postTriggers [ "post-2" ]
+ }
+
+ let options =
+ Assert.WantValueSome (
+ operation.RequestOptions,
+ "Delete builder should initialize request options when consistencyLevel is the first option set."
+ )
+ Assert.AreEqual (
+ Nullable ConsistencyLevel.Strong,
+ options.ConsistencyLevel,
+ "Delete builder should set consistency level when initializing fresh options."
+ )
+ Assert.IsTrue (options.EnableContentResponseOnWrite, "Delete builder should set content response mode.")
+ Assert.AreEqual (
+ Nullable IndexingDirective.Include,
+ options.IndexingDirective,
+ "Delete builder should set indexing directive."
+ )
+ CollectionAssert.AreEqual (
+ [| "pre-1"; "pre-2" |],
+ Array.ofSeq options.PreTriggers,
+ "Delete builder should accumulate pre-triggers in call order."
+ )
+ CollectionAssert.AreEqual (
+ [| "post-1"; "post-2" |],
+ Array.ofSeq options.PostTriggers,
+ "Delete builder should accumulate post-triggers in call order."
+ )
+
+ []
+ member _.``AddPreTrigger and AddPostTrigger accumulate across calls on fresh options`` () =
+ let options = ItemRequestOptions ()
+
+ options.AddPreTrigger "pre-1"
+ options.AddPreTrigger "pre-2"
+ options.AddPostTrigger "post-1"
+ options.AddPostTrigger "post-2"
+
+ CollectionAssert.AreEqual (
+ [| "pre-1"; "pre-2" |],
+ Array.ofSeq options.PreTriggers,
+ "AddPreTrigger should accumulate triggers across calls, including on freshly created options."
+ )
+ CollectionAssert.AreEqual (
+ [| "post-1"; "post-2" |],
+ Array.ofSeq options.PostTriggers,
+ "AddPostTrigger should accumulate triggers across calls, including on freshly created options."
+ )
+
+ []
+ member _.``AddPreTriggers and AddPostTriggers accumulate across calls on fresh options`` () =
+ let options = ItemRequestOptions ()
+
+ options.AddPreTriggers [ "pre-1"; "pre-2" ]
+ options.AddPreTriggers [ "pre-3" ]
+ options.AddPostTriggers [ "post-1"; "post-2" ]
+ options.AddPostTriggers [ "post-3" ]
+
+ CollectionAssert.AreEqual (
+ [| "pre-1"; "pre-2"; "pre-3" |],
+ Array.ofSeq options.PreTriggers,
+ "AddPreTriggers should accumulate triggers across calls, including on freshly created options."
+ )
+ CollectionAssert.AreEqual (
+ [| "post-1"; "post-2"; "post-3" |],
+ Array.ofSeq options.PostTriggers,
+ "AddPostTriggers should accumulate triggers across calls, including on freshly created options (regression test for the missing null-guard)."
+ )
+
+ []
+ member _.``AddPreTriggers and AddPostTriggers throw for null trigger sequence`` () =
+ let options = ItemRequestOptions ()
+
+ Assert.ThrowsExactly (
+ (fun () -> options.AddPreTriggers Unchecked.defaultof),
+ "AddPreTriggers should throw ArgumentNullException for a null sequence."
+ )
+ |> ignore
+
+ Assert.ThrowsExactly (
+ (fun () -> options.AddPostTriggers Unchecked.defaultof),
+ "AddPostTriggers should throw ArgumentNullException for a null sequence."
+ )
+ |> ignore
+
+ []
+ member _.``Unique key builders configure key and policy paths`` () =
+ let uniqueKeyDefinition = uniqueKey { paths [ "/tenantId"; "/email" ] }
+ let policy = uniqueKeyPolicy { key uniqueKeyDefinition }
+
+ Assert.HasCount (2, uniqueKeyDefinition.Paths, "UniqueKey builder should add all paths.")
+ Assert.HasCount (1, policy.UniqueKeys, "UniqueKeyPolicy builder should add unique key.")
+
+ []
+ member _.``Unique key builders support direct Yield seeding of a single path or key`` () =
+ // Note: the bare form `uniqueKey { "/direct-path" }` (no `yield`) does NOT reach
+ // `UniqueKeyBuilder.Yield(path : string)` — the builder has no `Combine`/`Delay`, so
+ // there is no implicit-last-expression-as-yield desugaring, and the bare string
+ // statement is silently ignored (F# warns FS0020) while `Yield` is invoked with `unit`,
+ // producing an empty key. An explicit `yield` is required to reach the string overload.
+ let uniqueKeyDefinition = uniqueKey { yield "/direct-path" }
+ let policy = uniqueKeyPolicy { yield uniqueKeyDefinition }
+
+ Assert.HasCount (1, uniqueKeyDefinition.Paths, "UniqueKey builder should seed a single path via direct Yield.")
+ Assert.Contains ("/direct-path", uniqueKeyDefinition.Paths, "UniqueKey builder should seed the given path.")
+ Assert.HasCount (1, policy.UniqueKeys, "UniqueKeyPolicy builder should seed a single key via direct Yield.")
diff --git a/tests/Cosmos.Tests/CosmosAssert.fs b/tests/Cosmos.Tests/CosmosAssert.fs
new file mode 100644
index 0000000..2dd14ac
--- /dev/null
+++ b/tests/Cosmos.Tests/CosmosAssert.fs
@@ -0,0 +1,207 @@
+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.Patch
+open FSharp.Azure.Cosmos.Read
+open FSharp.Azure.Cosmos.Replace
+open FSharp.Azure.Cosmos.Upsert
+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 : PatchResult<'T>, [] message) =
+ match result with
+ | PatchResult.Ok ok -> ok
+ | _ ->
+ Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected PatchResult.Ok but got {result}.")
+ Unchecked.defaultof<_>
+
+ static member IsOk (result : PatchResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore
+
+ static member WantOk<'T> (result : UpsertResult<'T>, [] message) =
+ match result with
+ | UpsertResult.Ok ok -> ok
+ | _ ->
+ Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected UpsertResult.Ok but got {result}.")
+ Unchecked.defaultof<_>
+
+ static member IsOk (result : UpsertResult<'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 WantNotFound<'T> (result : ReplaceResult<'T>, [] message) =
+ match result with
+ | ReplaceResult.NotFound response -> response
+ | _ ->
+ Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected ReplaceResult.NotFound but got {result}.")
+ Unchecked.defaultof<_>
+
+ static member IsNotFound (result : ReplaceResult<'T>, [] message) =
+ CosmosAssert.WantNotFound (result, message) |> ignore
+
+ static member WantNotFound<'T> (result : PatchResult<'T>, [] message) =
+ match result with
+ | PatchResult.NotFound response -> response
+ | _ ->
+ Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected PatchResult.NotFound but got {result}.")
+ Unchecked.defaultof<_>
+
+ static member IsNotFound (result : PatchResult<'T>, [] message) =
+ CosmosAssert.WantNotFound (result, message) |> ignore
+
+ static member WantModifiedBefore<'T> (result : UpsertResult<'T>, [] message) =
+ match result with
+ | UpsertResult.ModifiedBefore response -> response
+ | _ ->
+ Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected UpsertResult.ModifiedBefore but got {result}.")
+ Unchecked.defaultof<_>
+
+ static member IsModifiedBefore (result : UpsertResult<'T>, [] message) =
+ CosmosAssert.WantModifiedBefore (result, message) |> ignore
+
+ static member WantModifiedBefore<'T> (result : ReplaceResult<'T>, [] message) =
+ match result with
+ | ReplaceResult.ModifiedBefore response -> response
+ | _ ->
+ Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected ReplaceResult.ModifiedBefore but got {result}.")
+ Unchecked.defaultof<_>
+
+ static member IsModifiedBefore (result : ReplaceResult<'T>, [] message) =
+ CosmosAssert.WantModifiedBefore (result, message) |> ignore
+
+ static member WantModifiedBefore<'T> (result : PatchResult<'T>, [] message) =
+ match result with
+ | PatchResult.ModifiedBefore response -> response
+ | _ ->
+ Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected PatchResult.ModifiedBefore but got {result}.")
+ Unchecked.defaultof<_>
+
+ static member IsModifiedBefore (result : PatchResult<'T>, [] message) =
+ CosmosAssert.WantModifiedBefore (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
+
+ static member WantCustomError<'T, 'E> (result : UpsertConcurrentResult<'T, 'E>, [] message) =
+ match result with
+ | UpsertConcurrentResult.CustomError error -> error
+ | _ ->
+ Assert.Fail (
+ CosmosAssert.GetMessageOrDefault message $"Expected UpsertConcurrentResult.CustomError but got {result}."
+ )
+ Unchecked.defaultof<_>
+
+ static member WantCustomError<'T, 'E> (result : ReplaceConcurrentResult<'T, 'E>, [] message) =
+ match result with
+ | ReplaceConcurrentResult.CustomError error -> error
+ | _ ->
+ Assert.Fail (
+ CosmosAssert.GetMessageOrDefault message $"Expected ReplaceConcurrentResult.CustomError but got {result}."
+ )
+ Unchecked.defaultof<_>
+
+ static member WantModifiedBefore<'T, 'E> (result : UpsertConcurrentResult<'T, 'E>, [] message) =
+ match result with
+ | UpsertConcurrentResult.ModifiedBefore response -> response
+ | _ ->
+ Assert.Fail (
+ CosmosAssert.GetMessageOrDefault message $"Expected UpsertConcurrentResult.ModifiedBefore but got {result}."
+ )
+ Unchecked.defaultof<_>
+
+ static member IsModifiedBefore (result : UpsertConcurrentResult<'T, 'E>, [] message) =
+ CosmosAssert.WantModifiedBefore (result, message) |> ignore
+
+ static member WantModifiedBefore<'T, 'E> (result : ReplaceConcurrentResult<'T, 'E>, [] message) =
+ match result with
+ | ReplaceConcurrentResult.ModifiedBefore response -> response
+ | _ ->
+ Assert.Fail (
+ CosmosAssert.GetMessageOrDefault message $"Expected ReplaceConcurrentResult.ModifiedBefore but got {result}."
+ )
+ Unchecked.defaultof<_>
+
+ static member IsModifiedBefore (result : ReplaceConcurrentResult<'T, 'E>, [] message) =
+ CosmosAssert.WantModifiedBefore (result, message) |> ignore
diff --git a/tests/Cosmos.Tests/CosmosNameTests.fs b/tests/Cosmos.Tests/CosmosNameTests.fs
new file mode 100644
index 0000000..0aba137
--- /dev/null
+++ b/tests/Cosmos.Tests/CosmosNameTests.fs
@@ -0,0 +1,56 @@
+namespace FSharp.Azure.Cosmos.Tests
+
+open System
+open FSharp.Azure.Cosmos
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+[]
+type CosmosNameTests () =
+
+ []
+ []
+ []
+ []
+ []
+ []
+ member _.``ValidateField accepts valid field names`` (fieldName : string) = CosmosName.validateField "fieldName" fieldName
+
+ []
+ []
+ []
+ []
+ []
+ []
+ []
+ []
+ []
+ member _.``ValidateField throws ArgumentException for invalid field names`` (fieldName : string) =
+ Assert.ThrowsExactly (
+ (fun () -> CosmosName.validateField "fieldName" fieldName),
+ "ValidateField should throw ArgumentException for invalid field names."
+ )
+ |> ignore
+
+ []
+ member _.``ValidateField throws ArgumentNullException for null field name`` () =
+ Assert.ThrowsExactly (
+ (fun () -> CosmosName.validateField "fieldName" Unchecked.defaultof),
+ "ValidateField should throw ArgumentNullException for null field name."
+ )
+ |> ignore
+
+ []
+ member _.``ValidateField reports the caller supplied parameter name on failure`` () =
+ let exn =
+ Assert.ThrowsExactly (fun () -> CosmosName.validateField "customParam" "1invalid")
+
+ Assert.AreEqual ("customParam", exn.ParamName, "ValidateField should report the supplied paramName on failure.")
+
+ []
+ member _.``ValidateField reports the caller supplied parameter name on null`` () =
+ let exn =
+ Assert.ThrowsExactly (fun () ->
+ CosmosName.validateField "customParam" Unchecked.defaultof
+ )
+
+ Assert.AreEqual ("customParam", exn.ParamName, "ValidateField should report the supplied paramName on null.")
diff --git a/tests/Cosmos.Tests/CreateOperationTests.fs b/tests/Cosmos.Tests/CreateOperationTests.fs
new file mode 100644
index 0000000..59d6d1f
--- /dev/null
+++ b/tests/Cosmos.Tests/CreateOperationTests.fs
@@ -0,0 +1,90 @@
+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.AreEqual (HttpStatusCode.Created, createResponse.HttpStatusCode, "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.AreEqual (testItem.id, created.id, "Create should persist item id.")
+ Assert.AreEqual (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.AreEqual (testItem.id, created.id, "CreateAndRead should return created item id.")
+ Assert.AreEqual (testItem.partitionKey, created.partitionKey, "CreateAndRead should return created partition key.")
+ }
+
+ []
+ member this.``Create execute returns IdAlreadyExists for a duplicate id`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "create-duplicate"
+
+ let! firstResponse =
+ container.ExecuteAsync (
+ create {
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (firstResponse.Result, "First create should succeed.")
+
+ let! secondResponse =
+ container.ExecuteAsync (
+ create {
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsConflict (secondResponse.Result, "Create with a duplicate id should return CreateResult.IdAlreadyExists.")
+ Assert.AreEqual (HttpStatusCode.Conflict, secondResponse.HttpStatusCode, "Duplicate create should return HTTP 409.")
+ }
diff --git a/tests/Cosmos.Tests/DeleteOperationTests.fs b/tests/Cosmos.Tests/DeleteOperationTests.fs
new file mode 100644
index 0000000..0d56340
--- /dev/null
+++ b/tests/Cosmos.Tests/DeleteOperationTests.fs
@@ -0,0 +1,74 @@
+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.AreEqual (HttpStatusCode.NoContent, deleteResponse.HttpStatusCode, "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.AreEqual (HttpStatusCode.NotFound, missingResponse.HttpStatusCode, "Read after delete should return HTTP 404.")
+ }
+
+ []
+ member this.``Delete execute returns NotFound for a missing item`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "delete-missing"
+
+ let! deleteResponse =
+ container.ExecuteAsync (
+ delete {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsNotFound (deleteResponse.Result, "Delete of a never-created item should return DeleteResult.NotFound.")
+ Assert.AreEqual (
+ HttpStatusCode.NotFound,
+ deleteResponse.HttpStatusCode,
+ "Delete of a missing item 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..c846283 100644
--- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj
+++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj
@@ -17,7 +17,22 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..a89967f
--- /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.AreEqual (2, foundCount, "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.AreEqual (2, foundCount, "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..b7e8d52
--- /dev/null
+++ b/tests/Cosmos.Tests/PatchOperationTests.fs
@@ -0,0 +1,233 @@
+namespace FSharp.Azure.Cosmos.Tests.Integration
+
+open System
+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.AreEqual (HttpStatusCode.OK, patchResponse.HttpStatusCode, "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.")
+ Assert.AreEqual (patchedName, persisted.name, "Patch should persist patched name.")
+ Assert.AreEqual (patchedQuantity, persisted.quantity, "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 ->
+ Assert.AreEqual (patchedName, patched.name, "PatchAndRead should return patched name.")
+ Assert.AreEqual (patchedQuantity, patched.quantity, "PatchAndRead should return patched quantity.")
+ Assert.AreEqual (HttpStatusCode.OK, patchResponse.HttpStatusCode, "PatchAndRead should return HTTP 200.")
+ | result -> Assert.Fail ($"Expected patchAndRead success, got {result}.")
+ }
+
+ []
+ member this.``Patch execute overwrite returns NotFound for a missing item`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "patch-missing"
+
+ let! patchResponse =
+ container.ExecuteOverwriteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Replace ("/name", "irrelevant"))
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsNotFound (patchResponse.Result, "Patch of a never-created item should return PatchResult.NotFound.")
+ Assert.AreEqual (HttpStatusCode.NotFound, patchResponse.HttpStatusCode, "Patch of a missing item should return HTTP 404.")
+ }
+
+ []
+ member this.``Patch execute requires an ETag`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "patch-requires-etag"
+
+ let invoke () =
+ Func (fun () -> task {
+ let! _ =
+ container.ExecuteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Replace ("/name", "irrelevant"))
+ },
+ this.CancellationToken
+ )
+
+ return ()
+ })
+
+ let! _ =
+ Assert.ThrowsExactlyAsync (
+ invoke (),
+ "Patch safe execute should throw ArgumentException when no eTag is set."
+ )
+
+ return ()
+ }
+
+ []
+ member this.``Patch execute succeeds when the ETag matches`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "patch-matching-etag"
+
+ let! createResponse =
+ container.ExecuteAsync (
+ create {
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.")
+
+ let! patchResponse =
+ container.ExecuteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Replace ("/name", "item-patch-matching-etag"))
+ eTag createResponse.ETag
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (patchResponse.Result, "Safe patch with a matching eTag should return PatchResult.Ok.")
+ Assert.AreEqual (
+ HttpStatusCode.OK,
+ patchResponse.HttpStatusCode,
+ "Safe patch with a matching eTag should return HTTP 200."
+ )
+ }
+
+ []
+ member this.``Patch execute returns ModifiedBefore for a stale ETag`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "patch-stale-etag"
+
+ let! createResponse =
+ container.ExecuteAsync (
+ create {
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.")
+ let staleETag = createResponse.ETag
+
+ let! overwriteResponse =
+ container.ExecuteOverwriteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Replace ("/name", "item-patch-stale-etag-changed"))
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (overwriteResponse.Result, "Overwrite that changes the ETag should succeed.")
+
+ let! stalePatchResponse =
+ container.ExecuteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Replace ("/name", "item-patch-stale-etag-final"))
+ eTag staleETag
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsModifiedBefore (
+ stalePatchResponse.Result,
+ "Safe patch with a stale eTag should return PatchResult.ModifiedBefore."
+ )
+ Assert.AreEqual (
+ HttpStatusCode.PreconditionFailed,
+ stalePatchResponse.HttpStatusCode,
+ "Safe patch with a stale eTag should return HTTP 412."
+ )
+ }
diff --git a/tests/Cosmos.Tests/ReadExtensionsTests.fs b/tests/Cosmos.Tests/ReadExtensionsTests.fs
new file mode 100644
index 0000000..97bb0db
--- /dev/null
+++ b/tests/Cosmos.Tests/ReadExtensionsTests.fs
@@ -0,0 +1,213 @@
+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.AreEqual (3, countByPartition, "CountAsync by partition should return seeded item count.")
+ Assert.AreEqual (3, countByQuery, "CountAsync by query options should return seeded item count.")
+ Assert.AreEqual (3L, longCountByPartition, "LongCountAsync should return seeded item count.")
+ }
+
+ []
+ member this.``ExistsAsync returns expected values for partition key variants`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "exists"
+ do! this.SeedItemsAsync (container, [ testItem ])
+
+ let! existsWithPartition = container.ExistsAsync (testItem.id, PartitionKey testItem.partitionKey, this.CancellationToken)
+
+ let! existsWithoutPartition = container.ExistsAsync (testItem.id, cancellationToken = this.CancellationToken)
+
+ let! missingExists = container.ExistsAsync ($"{testItem.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.")
+ }
+
+ []
+ []
+ []
+ []
+ member this.``IsNotDeletedAsync evaluates valid deleted field name shapes in the query`` (deletedFieldName : string) : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "valid-field-name"
+ do! this.SeedItemsAsync (container, [ testItem ])
+
+ let! notDeletedBefore = container.IsNotDeletedAsync deletedFieldName testItem.id
+
+ Assert.IsTrue (notDeletedBefore, $"IsNotDeletedAsync should return true before the '{deletedFieldName}' marker is set.")
+
+ let! patchResponse =
+ container.ExecuteOverwriteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Set ($"/{deletedFieldName}", true))
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (patchResponse.Result, $"Setting the '{deletedFieldName}' marker should succeed.")
+
+ let! notDeletedAfter = container.IsNotDeletedAsync deletedFieldName testItem.id
+
+ Assert.IsFalse (notDeletedAfter, $"IsNotDeletedAsync should return false once the '{deletedFieldName}' marker is true.")
+ }
+
+ []
+ member this.``IsNotDeletedAsync returns true when deleted marker field is undefined`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "marker-undefined"
+ do! this.SeedItemsAsync (container, [ testItem ])
+
+ let! notDeleted = container.IsNotDeletedAsync "deletedAt" testItem.id
+
+ Assert.IsTrue (notDeleted, "IsNotDeletedAsync should return true when the deleted marker field is undefined.")
+ }
+
+ []
+ member this.``IsNotDeletedAsync returns true when deleted marker field is null`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "marker-null"
+ do! this.SeedItemsAsync (container, [ testItem ])
+
+ let! patchResponse =
+ container.ExecuteOverwriteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Set ("/deletedAt", Unchecked.defaultof))
+ },
+ this.CancellationToken
+ )
+
+ match patchResponse.Result with
+ | PatchResult.Ok _ -> ()
+ | result -> Assert.Fail ($"Expected patch success setting null marker, got {result}.")
+
+ let! notDeleted = container.IsNotDeletedAsync "deletedAt" testItem.id
+
+ Assert.IsTrue (notDeleted, "IsNotDeletedAsync should return true when the deleted marker field is null.")
+ }
+
+ []
+ member this.``IsNotDeletedAsync returns true when deleted marker field is false`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "marker-false"
+ do! this.SeedItemsAsync (container, [ testItem ])
+
+ let! patchResponse =
+ container.ExecuteOverwriteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Set ("/deletedAt", false))
+ },
+ this.CancellationToken
+ )
+
+ match patchResponse.Result with
+ | PatchResult.Ok _ -> ()
+ | result -> Assert.Fail ($"Expected patch success setting false marker, got {result}.")
+
+ let! notDeleted = container.IsNotDeletedAsync "deletedAt" testItem.id
+
+ Assert.IsTrue (notDeleted, "IsNotDeletedAsync should return true when the deleted marker field is explicitly false.")
+ }
+
+ []
+ member this.``IsNotDeletedAsync returns false when deleted marker field is true`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "marker-true"
+ do! this.SeedItemsAsync (container, [ testItem ])
+
+ let! patchResponse =
+ container.ExecuteOverwriteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Set ("/deletedAt", true))
+ },
+ this.CancellationToken
+ )
+
+ match patchResponse.Result with
+ | PatchResult.Ok _ -> ()
+ | result -> Assert.Fail ($"Expected patch success setting true marker, got {result}.")
+
+ let! notDeleted = container.IsNotDeletedAsync "deletedAt" testItem.id
+
+ Assert.IsFalse (notDeleted, "IsNotDeletedAsync should return false when the deleted marker field is true.")
+ }
+
+ []
+ member this.``IsNotDeletedAsync returns false when deleted marker field is a timestamp`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "marker-timestamp"
+ do! this.SeedItemsAsync (container, [ testItem ])
+
+ let! patchResponse =
+ container.ExecuteOverwriteAsync (
+ patch {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ operation (PatchOperation.Set ("/deletedAt", "2026-05-24T00:00:00Z"))
+ },
+ this.CancellationToken
+ )
+
+ match patchResponse.Result with
+ | PatchResult.Ok _ -> Assert.AreEqual (HttpStatusCode.OK, patchResponse.HttpStatusCode, "Patch should return HTTP 200.")
+ | result -> Assert.Fail ($"Expected patch success, got {result}.")
+
+ let! notDeleted = container.IsNotDeletedAsync "deletedAt" testItem.id
+
+ Assert.IsFalse (notDeleted, "IsNotDeletedAsync should return false when the deleted marker field is a timestamp.")
+ }
+
+ []
+ member this.``IsNotDeletedAsync throws for null or malformed deleted field names`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "invalid-deleted-field-name"
+
+ 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 (
+ Func (fun () -> task {
+ let! _ = container.IsNotDeletedAsync "1invalid" testItem.id
+ return ()
+ }),
+ "IsNotDeletedAsync should throw ArgumentException for a malformed deleted field name."
+ )
+
+ return ()
+ }
diff --git a/tests/Cosmos.Tests/ReadManyOperationTests.fs b/tests/Cosmos.Tests/ReadManyOperationTests.fs
new file mode 100644
index 0000000..a23a0f8
--- /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.HasCount (2, feed, "ReadMany should return requested number of items.")
+ Assert.Contains (firstItem.id, returnedIds, "ReadMany should include first item.")
+ Assert.Contains (secondItem.id, returnedIds, "ReadMany should include second item.")
+ Assert.AreEqual (HttpStatusCode.OK, readManyResponse.HttpStatusCode, "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..e42faeb
--- /dev/null
+++ b/tests/Cosmos.Tests/ReadOperationTests.fs
@@ -0,0 +1,211 @@
+namespace FSharp.Azure.Cosmos.Tests.Integration
+
+open System
+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 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.AreEqual (testItem.id, found.id, "Read should return created item.")
+ Assert.AreEqual (HttpStatusCode.OK, foundResponse.HttpStatusCode, "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.AreEqual (HttpStatusCode.NotFound, missingResponse.HttpStatusCode, "Read missing should return HTTP 404.")
+ }
+
+ []
+ member this.``Read execute returns NotModified when eTag matches current item`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.Application.SeededItem
+
+ let! foundResponse =
+ container.ExecuteAsync (
+ read {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (foundResponse.Result, "Baseline read should succeed.")
+ Assert.IsFalse (String.IsNullOrEmpty foundResponse.ETag, "Baseline read should return an ETag.")
+
+ let! notModifiedResponse =
+ container.ExecuteAsync (
+ read {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ eTag foundResponse.ETag
+ },
+ this.CancellationToken
+ )
+
+ match notModifiedResponse.Result with
+ | ReadResult.NotModified -> ()
+ | ReadResult.Ok _ when notModifiedResponse.HttpStatusCode = HttpStatusCode.OK ->
+ // The service ignored If-None-Match and returned the full document (observed on the Linux
+ // vnext-preview emulator). NotModified cannot be produced without a 304 from the service, so this
+ // environment cannot verify conditional reads; report that instead of passing or failing.
+ // Tracked upstream: https://github.com/Azure/azure-cosmos-db-emulator-docker/issues/349
+ Assert.Inconclusive (
+ "The Cosmos DB endpoint ignored If-None-Match and returned HTTP 200, so conditional reads cannot be verified against it."
+ )
+ | result ->
+ Assert.Fail (
+ $"Expected ReadResult.NotModified for a matching eTag, got {result} (HTTP {int notModifiedResponse.HttpStatusCode})."
+ )
+
+ Assert.AreEqual (
+ HttpStatusCode.NotModified,
+ notModifiedResponse.HttpStatusCode,
+ "Read with a matching eTag should report HTTP 304."
+ )
+ }
+
+ []
+ member this.``ExecuteAsyncOption returns Some for existing item and None for missing item`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.Application.SeededItem
+
+ let! foundOption =
+ container.ExecuteAsyncOption (
+ read {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ let found =
+ Assert.WantSome (foundOption.Result, "ExecuteAsyncOption should return Some for an existing item.")
+ Assert.AreEqual (testItem.id, found.id, "ExecuteAsyncOption should return the existing item.")
+
+ let! missingOption =
+ container.ExecuteAsyncOption (
+ read {
+ id $"{testItem.id}-missing"
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ Assert.IsNone (missingOption.Result, "ExecuteAsyncOption should return None for a missing item.")
+ }
+
+ []
+ member this.``ExecuteAsyncValueOption returns ValueSome for existing item and ValueNone for missing item`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.Application.SeededItem
+
+ let! foundValueOption =
+ container.ExecuteAsyncValueOption (
+ read {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ let found =
+ Assert.WantValueSome (
+ foundValueOption.Result,
+ "ExecuteAsyncValueOption should return ValueSome for an existing item."
+ )
+ Assert.AreEqual (testItem.id, found.id, "ExecuteAsyncValueOption should return the existing item.")
+
+ let! missingValueOption =
+ container.ExecuteAsyncValueOption (
+ read {
+ id $"{testItem.id}-missing"
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ Assert.IsValueNone (missingValueOption.Result, "ExecuteAsyncValueOption should return ValueNone for a missing item.")
+ }
+
+ []
+ member this.``FeedIterator FirstAsync returns Ok for matching query and NotFound for empty query`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.Application.SeededItem
+
+ let matchingQuery =
+ QueryDefinition("SELECT * FROM c WHERE c.id = @id").WithParameter ("@id", testItem.id)
+ let matchingIterator = container.GetItemQueryIterator (matchingQuery)
+ let! matchingResponse = matchingIterator.FirstAsync (this.CancellationToken)
+
+ let found =
+ CosmosAssert.WantOk (matchingResponse.Result, "FirstAsync should return ReadResult.Ok for a matching query.")
+ Assert.AreEqual (testItem.id, found.id, "FirstAsync should return the matching item.")
+
+ let emptyQuery =
+ QueryDefinition("SELECT * FROM c WHERE c.id = @id").WithParameter ("@id", $"{testItem.id}-missing")
+ let emptyIterator = container.GetItemQueryIterator (emptyQuery)
+ let! emptyResponse = emptyIterator.FirstAsync (this.CancellationToken)
+
+ CosmosAssert.IsNotFound (emptyResponse.Result, "FirstAsync should return ReadResult.NotFound for an empty query result.")
+ }
diff --git a/tests/Cosmos.Tests/ReplaceOperationTests.fs b/tests/Cosmos.Tests/ReplaceOperationTests.fs
new file mode 100644
index 0000000..7caf25d
--- /dev/null
+++ b/tests/Cosmos.Tests/ReplaceOperationTests.fs
@@ -0,0 +1,370 @@
+namespace FSharp.Azure.Cosmos.Tests.Integration
+
+open System
+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.AreEqual (HttpStatusCode.OK, replaceResponse.HttpStatusCode, "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.AreEqual (replacement.name, persisted.name, "Replace should persist replacement name.")
+ Assert.AreEqual (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.AreEqual (replacement.name, replaced.name, "ReplaceAndRead should return replacement name.")
+ Assert.AreEqual (replacement.quantity, replaced.quantity, "ReplaceAndRead should return replacement quantity.")
+ Assert.AreEqual (HttpStatusCode.OK, replaceResponse.HttpStatusCode, "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.AreEqual ("replace-concurrent-updated", updated.name, "Replace concurrently should persist updated name.")
+ Assert.AreEqual (original.quantity + 10, updated.quantity, "Replace concurrently should persist updated quantity.")
+ | result -> Assert.Fail ($"Expected replace concurrently success after retry, got {result}.")
+ }
+
+ []
+ member this.``Replace execute overwrite returns NotFound for a missing item`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "replace-missing"
+
+ let! replaceResponse =
+ container.ExecuteOverwriteAsync (
+ replace {
+ id testItem.id
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsNotFound (replaceResponse.Result, "Replace of a never-created item should return ReplaceResult.NotFound.")
+ Assert.AreEqual (
+ HttpStatusCode.NotFound,
+ replaceResponse.HttpStatusCode,
+ "Replace of a missing item should return HTTP 404."
+ )
+ }
+
+ []
+ member this.``Replace execute requires an ETag`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "replace-requires-etag"
+
+ let invoke () =
+ Func (fun () -> task {
+ let! _ =
+ container.ExecuteAsync (
+ replace {
+ id testItem.id
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ return ()
+ })
+
+ let! _ =
+ Assert.ThrowsExactlyAsync (
+ invoke (),
+ "Replace safe execute should throw ArgumentException when no eTag is set."
+ )
+
+ return ()
+ }
+
+ []
+ member this.``Replace execute succeeds when the ETag matches`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "replace-matching-etag"
+
+ 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-replace-matching-etag"; quantity = 4 }
+
+ let! replaceResponse =
+ container.ExecuteAsync (
+ replace {
+ id replacement.id
+ item replacement
+ partitionKey replacement.partitionKey
+ eTag createResponse.ETag
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (replaceResponse.Result, "Safe replace with a matching eTag should return ReplaceResult.Ok.")
+ Assert.AreEqual (
+ HttpStatusCode.OK,
+ replaceResponse.HttpStatusCode,
+ "Safe replace with a matching eTag should return HTTP 200."
+ )
+ }
+
+ []
+ member this.``Replace execute returns ModifiedBefore for a stale ETag`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "replace-stale-etag"
+
+ let! createResponse =
+ container.ExecuteAsync (
+ create {
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.")
+ let staleETag = createResponse.ETag
+
+ let! overwriteResponse =
+ container.ExecuteOverwriteAsync (
+ replace {
+ id testItem.id
+ item { testItem with name = "item-replace-stale-etag-changed"; quantity = 2 }
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (overwriteResponse.Result, "Overwrite that changes the ETag should succeed.")
+
+ let! staleReplaceResponse =
+ container.ExecuteAsync (
+ replace {
+ id testItem.id
+ item { testItem with name = "item-replace-stale-etag-final"; quantity = 3 }
+ partitionKey testItem.partitionKey
+ eTag staleETag
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsModifiedBefore (
+ staleReplaceResponse.Result,
+ "Safe replace with a stale eTag should return ReplaceResult.ModifiedBefore."
+ )
+ Assert.AreEqual (
+ HttpStatusCode.PreconditionFailed,
+ staleReplaceResponse.HttpStatusCode,
+ "Safe replace with a stale eTag should return HTTP 412."
+ )
+ }
+
+ []
+ member this.``Replace concurrently returns CustomError when update reports an error`` () : Task = task {
+ let! container = this.GetContainer ()
+ let original = this.NewItem "replace-concurrent-custom-error"
+
+ let! createResponse =
+ container.ExecuteAsync (
+ create {
+ item original
+ partitionKey original.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.")
+
+ let operation = replaceConcurrenly {
+ id original.id
+ partitionKey original.partitionKey
+ update (fun _ -> async { return Result.Error "update rejected" })
+ }
+
+ let! concurrentResponse = container.ExecuteConcurrentlyAsync (operation, 3, this.CancellationToken)
+
+ let customError =
+ CosmosAssert.WantCustomError (
+ concurrentResponse.Result,
+ "Replace concurrently should return ReplaceConcurrentResult.CustomError when update reports an error."
+ )
+ Assert.AreEqual ("update rejected", customError, "Replace concurrently CustomError should carry the reported error.")
+ }
+
+ []
+ member this.``Replace concurrently returns ModifiedBefore when retries are exhausted`` () : Task = task {
+ let! container = this.GetContainer ()
+ let original = this.NewItem "replace-concurrent-exhausted"
+
+ let! createResponse =
+ container.ExecuteAsync (
+ create {
+ item original
+ partitionKey original.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.")
+
+ let operation = replaceConcurrenly {
+ id original.id
+ partitionKey original.partitionKey
+ update (fun current -> async {
+ // Always inject a competing write first, so the single allowed attempt
+ // (maxRetryCount = 1) always observes a stale ETag and exhausts immediately.
+ let competingUpdate = { current with name = "competing-exhaustion-update" }
+
+ let! _ =
+ container.ExecuteOverwriteAsync (
+ replace {
+ id competingUpdate.id
+ item competingUpdate
+ partitionKey competingUpdate.partitionKey
+ },
+ this.CancellationToken
+ )
+ |> Async.AwaitTask
+
+ return Result.Ok { current with name = "should-not-be-persisted" }
+ })
+ }
+
+ let! concurrentResponse = container.ExecuteConcurrentlyAsync (operation, 1, this.CancellationToken)
+
+ CosmosAssert.IsModifiedBefore (
+ concurrentResponse.Result,
+ "Replace concurrently should return ReplaceConcurrentResult.ModifiedBefore once retries are exhausted."
+ )
+ }
diff --git a/tests/Cosmos.Tests/TestCategories.fs b/tests/Cosmos.Tests/TestCategories.fs
new file mode 100644
index 0000000..363c452
--- /dev/null
+++ b/tests/Cosmos.Tests/TestCategories.fs
@@ -0,0 +1,36 @@
+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"
+
+ []
+ let Validation = "Validation"
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..26e19f1
--- /dev/null
+++ b/tests/Cosmos.Tests/UpsertOperationTests.fs
@@ -0,0 +1,383 @@
+namespace FSharp.Azure.Cosmos.Tests.Integration
+
+open System
+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.AreEqual (HttpStatusCode.Created, createResult.HttpStatusCode, "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.AreEqual (HttpStatusCode.OK, updateResult.HttpStatusCode, "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.AreEqual (updated.name, persisted.name, "Upsert should persist updated name.")
+ Assert.AreEqual (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.AreEqual (testItem.name, created.name, "UpsertAndRead create should return created resource.")
+ Assert.AreEqual (
+ HttpStatusCode.Created,
+ createdResponse.HttpStatusCode,
+ "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.AreEqual (updated.name, upserted.name, "UpsertAndRead update should return updated name.")
+ Assert.AreEqual (updated.quantity, upserted.quantity, "UpsertAndRead update should return updated quantity.")
+ Assert.AreEqual (HttpStatusCode.OK, updatedResponse.HttpStatusCode, "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.AreEqual ("upsert-concurrent-updated", updated.name, "Upsert concurrently should persist updated name.")
+ Assert.AreEqual (original.quantity + 7, updated.quantity, "Upsert concurrently should persist updated quantity.")
+ | result -> Assert.Fail ($"Expected upsert concurrently success after retry, got {result}.")
+ }
+
+ []
+ member this.``Upsert execute requires an ETag`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "upsert-requires-etag"
+
+ let invoke () =
+ Func (fun () -> task {
+ let! _ =
+ container.ExecuteAsync (
+ upsert {
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ return ()
+ })
+
+ let! _ =
+ Assert.ThrowsExactlyAsync (
+ invoke (),
+ "Upsert safe execute should throw ArgumentException when no eTag is set."
+ )
+
+ return ()
+ }
+
+ []
+ member this.``Upsert execute succeeds when the ETag matches`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "upsert-matching-etag"
+
+ let! createResponse =
+ container.ExecuteAsync (
+ create {
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.")
+
+ let updated = { testItem with name = "item-upsert-matching-etag"; quantity = 8 }
+
+ let! upsertResponse =
+ container.ExecuteAsync (
+ upsert {
+ item updated
+ partitionKey updated.partitionKey
+ eTag createResponse.ETag
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (upsertResponse.Result, "Safe upsert with a matching eTag should return UpsertResult.Ok.")
+ Assert.AreEqual (
+ HttpStatusCode.OK,
+ upsertResponse.HttpStatusCode,
+ "Safe upsert with a matching eTag should return HTTP 200."
+ )
+ }
+
+ []
+ member this.``Upsert execute returns ModifiedBefore for a stale ETag`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "upsert-stale-etag"
+
+ let! createResponse =
+ container.ExecuteAsync (
+ create {
+ item testItem
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.")
+ let staleETag = createResponse.ETag
+
+ let! overwriteResponse =
+ container.ExecuteOverwriteAsync (
+ upsert {
+ item { testItem with name = "item-upsert-stale-etag-changed"; quantity = 2 }
+ partitionKey testItem.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (overwriteResponse.Result, "Overwrite that changes the ETag should succeed.")
+
+ let! staleUpsertResponse =
+ container.ExecuteAsync (
+ upsert {
+ item { testItem with name = "item-upsert-stale-etag-final"; quantity = 3 }
+ partitionKey testItem.partitionKey
+ eTag staleETag
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsModifiedBefore (
+ staleUpsertResponse.Result,
+ "Safe upsert with a stale eTag should return UpsertResult.ModifiedBefore."
+ )
+ Assert.AreEqual (
+ HttpStatusCode.PreconditionFailed,
+ staleUpsertResponse.HttpStatusCode,
+ "Safe upsert with a stale eTag should return HTTP 412."
+ )
+ }
+
+ []
+ member this.``Upsert concurrently returns CustomError when update reports an error`` () : Task = task {
+ let! container = this.GetContainer ()
+ let original = this.NewItem "upsert-concurrent-custom-error"
+
+ let! createResponse =
+ container.ExecuteAsync (
+ create {
+ item original
+ partitionKey original.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.")
+
+ let operation = upsertConcurrenly {
+ id original.id
+ partitionKey original.partitionKey
+ updateOrCreate (fun _ -> async { return Result.Error "update rejected" })
+ }
+
+ let! concurrentResponse = container.ExecuteConcurrentlyAsync (operation, 3, this.CancellationToken)
+
+ let customError =
+ CosmosAssert.WantCustomError (
+ concurrentResponse.Result,
+ "Upsert concurrently should return UpsertConcurrentResult.CustomError when update reports an error."
+ )
+ Assert.AreEqual ("update rejected", customError, "Upsert concurrently CustomError should carry the reported error.")
+ }
+
+ []
+ member this.``Upsert concurrently returns ModifiedBefore when retries are exhausted`` () : Task = task {
+ let! container = this.GetContainer ()
+ let original = this.NewItem "upsert-concurrent-exhausted"
+
+ let! createResponse =
+ container.ExecuteAsync (
+ create {
+ item original
+ partitionKey original.partitionKey
+ },
+ this.CancellationToken
+ )
+
+ CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.")
+
+ let operation = upsertConcurrenly {
+ id original.id
+ partitionKey original.partitionKey
+ updateOrCreate (fun maybeCurrent -> async {
+ match maybeCurrent with
+ | Some current ->
+ // Always inject a competing write first, so the single allowed attempt
+ // (maxRetryCount = 1) always observes a stale ETag and exhausts immediately.
+ let competingUpdate = { current with name = "competing-exhaustion-update" }
+
+ let! _ =
+ container.ExecuteOverwriteAsync (
+ upsert {
+ item competingUpdate
+ partitionKey competingUpdate.partitionKey
+ },
+ this.CancellationToken
+ )
+ |> Async.AwaitTask
+
+ return Result.Ok { current with name = "should-not-be-persisted" }
+ | None -> return Result.Error "Expected existing item for exhaustion test."
+ })
+ }
+
+ let! concurrentResponse = container.ExecuteConcurrentlyAsync (operation, 1, this.CancellationToken)
+
+ CosmosAssert.IsModifiedBefore (
+ concurrentResponse.Result,
+ "Upsert concurrently should return UpsertConcurrentResult.ModifiedBefore once retries are exhausted."
+ )
+ }
+
+ []
+ member this.``Upsert concurrently creates item when it does not exist`` () : Task = task {
+ let! container = this.GetContainer ()
+ let testItem = this.NewItem "upsert-concurrent-create"
+
+ let operation = upsertConcurrenly {
+ id testItem.id
+ partitionKey testItem.partitionKey
+ updateOrCreate (
+ function
+ | None -> async { return Result.Ok testItem }
+ | Some _ -> async { return Result.Error "Expected no existing item for create test." }
+ )
+ }
+
+ let! concurrentResponse = container.ExecuteConcurrentlyAsync (operation, 3, this.CancellationToken)
+
+ match concurrentResponse.Result with
+ | UpsertConcurrentResult.Ok created ->
+ Assert.AreEqual (testItem.id, created.id, "Upsert concurrently create branch should persist the new item's id.")
+ Assert.AreEqual (testItem.name, created.name, "Upsert concurrently create branch should persist the new item's name.")
+ | result -> Assert.Fail ($"Expected upsert concurrently create success, got {result}.")
+ }