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}.") + }