diff --git a/src/Cosmos/Cosmos.fs b/src/Cosmos/Cosmos.fs index 5f0e742..f596c26 100644 --- a/src/Cosmos/Cosmos.fs +++ b/src/Cosmos/Cosmos.fs @@ -70,6 +70,10 @@ module Operations = type ItemRequestOptions with + /// + /// Adds a pre-trigger to request options. + /// + /// Trigger name. member options.AddPreTrigger (trigger : string) = options.PreTriggers <- [| if not <| isNull options.PreTriggers then @@ -77,6 +81,11 @@ module Operations = yield trigger |] + /// + /// Adds pre-triggers to request options. + /// + /// Trigger names. + /// Thrown when is null. member options.AddPreTriggers (triggers : string seq) = if obj.ReferenceEquals (triggers, null) then raise (ArgumentNullException (nameof triggers)) @@ -93,6 +102,11 @@ module Operations = yield trigger |] + /// + /// Adds post-triggers to request options. + /// + /// Trigger names. + /// Thrown when is null. member options.AddPostTriggers (triggers : string seq) = if obj.ReferenceEquals (triggers, null) then raise (ArgumentNullException (nameof triggers)) @@ -211,25 +225,55 @@ module Operations = /// /// Checks if an item with specified Id exists in the container partition with specified key. /// + /// Deleted marker field name. /// Item Id - /// Partition key + /// Request options /// Cancellation token + /// Thrown when is null. + /// + /// Thrown when does not start with a letter or underscore, + /// or contains characters other than letters, digits, or underscores. + /// member container.IsNotDeletedAsync - deletedFieldName - (id : string, [] requiestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken) + (deletedFieldName : string) + (id : string, [] requestOptions : QueryRequestOptions, [] cancellationToken : CancellationToken) = + if obj.ReferenceEquals (deletedFieldName, null) then + nullArg (nameof deletedFieldName) + task { + let isAsciiLetter c = ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') + let isAsciiDigit c = '0' <= c && c <= '9' + + let isValidDeletedFieldName = + if String.IsNullOrWhiteSpace deletedFieldName then + false + else + let firstCharacter = deletedFieldName[0] + let hasValidStart = firstCharacter = '_' || isAsciiLetter firstCharacter + let hasValidBody = + deletedFieldName + |> Seq.forall (fun c -> c = '_' || isAsciiLetter c || isAsciiDigit c) + + hasValidStart && hasValidBody + + if not isValidDeletedFieldName then + invalidArg + (nameof deletedFieldName) + "Deleted field name must start with a letter or underscore and contain only letters, digits, or underscores." + let query = QueryDefinition( - $"SELECT VALUE COUNT(1) \ - FROM item \ - WHERE item.id = @Id AND IS_NULL(item.{deletedFieldName})" + $"""SELECT VALUE COUNT(1) + FROM item + WHERE item.id = @Id + AND (NOT IS_DEFINED(item.{deletedFieldName}) OR IS_NULL(item.{deletedFieldName}))""" ) .WithParameter ("@Id", id) let! count = container.GetItemQueryIterator ( query, - requestOptions = getRequestOptionsWithMaxItemCount1 requiestOptions + requestOptions = getRequestOptionsWithMaxItemCount1 requestOptions ) |> CancellableTaskSeq.ofFeedIterator cancellationToken |> TaskSeq.tryHead diff --git a/tests/Cosmos.Tests/Assert.fs b/tests/Cosmos.Tests/Assert.fs new file mode 100644 index 0000000..7c6e450 --- /dev/null +++ b/tests/Cosmos.Tests/Assert.fs @@ -0,0 +1,72 @@ +namespace FSharp.Azure.Cosmos.Tests + +open System.Runtime.InteropServices +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +module AssertExtensions = + + type Assert with + + static member WantSome (value, [] message : string | null) = + match value with + | Some some -> some + | None -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsSome (value, [] message : string | null) = Assert.WantSome (value, message) |> ignore + + static member IsNone (value, [] message : string | null) = + match value with + | Some _ -> Assert.Fail (message) + | None -> () + + static member WantValueSome (value, [] message : string | null) = + match value with + | ValueSome some -> some + | ValueNone -> + Assert.Fail (message) + Unchecked.defaultof<_> + + static member IsValueSome (value, [] message : string | null) = Assert.WantValueSome (value, message) |> ignore + + static member IsValueNone (value, [] message : string | null) = + match value with + | ValueSome _ -> Assert.Fail (message) + | ValueNone -> () + + static member WantOk (value, [] message : string | null) = + match value with + | Ok ok -> ok + | Error error -> + match message with + | null -> Assert.Fail (string error) + | message -> Assert.Fail ($"'{message}': {error}") + Unchecked.defaultof<_> + + static member IsOk (value, [] message : string | null) = Assert.WantOk (value, message) |> ignore + + static member WantError (value, [] message : string | null) = + match value with + | Error error -> error + | Ok value -> + match message with + | null -> Assert.Fail (string value) + | message -> Assert.Fail ($"'{message}': {value}") + Unchecked.defaultof<_> + + static member IsError (value, [] message : string | null) = Assert.WantError (value, message) |> ignore + + static member inline IsDefaultOf< ^T> (value : ^T, [] message : string) = + Assert.AreEqual (box value, box Unchecked.defaultof< ^T>, message) + + static member inline OkEquals< ^R, 'E> (expected : ^R, actual : Result< ^R, 'E >, [] message : string | null) = + Assert.AreEqual (box expected, box (Assert.WantOk (actual, message)), message) + + static member inline ErrorEquals<'R, ^E> (expected : ^E, actual : Result<'R, ^E>, [] message : string | null) = + Assert.AreEqual (box expected, box (Assert.WantError (actual, message)), message) + + static member FailWithData<'T> ([] message : string | null) = + Assert.Fail (message) + Unchecked.defaultof<'T> diff --git a/tests/Cosmos.Tests/BuilderUnitTests.fs b/tests/Cosmos.Tests/BuilderUnitTests.fs new file mode 100644 index 0000000..2743bf2 --- /dev/null +++ b/tests/Cosmos.Tests/BuilderUnitTests.fs @@ -0,0 +1,233 @@ +namespace FSharp.Azure.Cosmos.Tests + +open System +open FSharp.Azure.Cosmos +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +type private BuilderTestItem = { id : string; partitionKey : string; value : int } + +[] +type BuilderUnitTests () = + + [] + member _.``Create builders configure operation and content response mode`` () = + let createItem = { id = "create-id"; partitionKey = "pk"; value = 1 } + + let createOperation = create { + item createItem + partitionKey createItem.partitionKey + sessionToken "create-session" + } + + let createAndReadOperation = createAndRead { + item createItem + partitionKey createItem.partitionKey + sessionToken "create-and-read-session" + } + + Assert.IsValueSome (createOperation.PartitionKey, "Create builder should set partition key.") + Assert.IsTrue (createOperation.RequestOptions.SessionToken = "create-session", "Create builder should set session token.") + Assert.IsFalse ( + createOperation.RequestOptions.EnableContentResponseOnWrite, + "Create builder should disable content response." + ) + Assert.IsTrue ( + createAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "CreateAndRead builder should enable content response." + ) + + [] + member _.``Read builder configures id partition key and request options`` () = + let operation = read { + id "read-id" + partitionKey "pk" + eTag "etag-value" + sessionToken "read-session" + } + + Assert.IsTrue (operation.Id = "read-id", "Read builder should set id.") + Assert.IsFalse (isNull operation.RequestOptions, "Read builder should initialize request options when needed.") + Assert.IsTrue (operation.RequestOptions.IfNoneMatchEtag = "etag-value", "Read builder should set eTag option.") + Assert.IsTrue (operation.RequestOptions.SessionToken = "read-session", "Read builder should set session token.") + + [] + member _.``ReadMany builder collects item tuples and request options`` () = + let operation = readMany { + item "item-1" "pk" + item "item-2" (PartitionKey "pk") + sessionToken "readmany-session" + } + + Assert.IsTrue (operation.Items.Length = 2, "ReadMany builder should collect all item tuples.") + Assert.IsFalse (isNull operation.RequestOptions, "ReadMany builder should create request options when needed.") + Assert.IsTrue (operation.RequestOptions.SessionToken = "readmany-session", "ReadMany builder should set session token.") + + [] + member _.``Replace builders configure operation and content response mode`` () = + let replaceItem = { id = "replace-id"; partitionKey = "pk"; value = 1 } + + let replaceOperation = replace { + id replaceItem.id + item replaceItem + partitionKey replaceItem.partitionKey + eTag "replace-etag" + } + + let replaceAndReadOperation = replaceAndRead { + id replaceItem.id + item replaceItem + partitionKey replaceItem.partitionKey + } + + Assert.IsTrue (replaceOperation.Id = replaceItem.id, "Replace builder should set id.") + Assert.IsTrue (replaceOperation.RequestOptions.IfMatchEtag = "replace-etag", "Replace builder should set eTag.") + Assert.IsFalse ( + replaceOperation.RequestOptions.EnableContentResponseOnWrite, + "Replace builder should disable content response." + ) + Assert.IsTrue ( + replaceAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "ReplaceAndRead builder should enable content response." + ) + + [] + member _.``Replace concurrently builders configure update function and response mode`` () = + let replaceConcurrentlyOperation = replaceConcurrenly { + id "replace-concurrent-id" + partitionKey "pk" + update (fun item -> async { return Result.Ok { item with value = item.value + 1 } }) + } + + let replaceConcurrentlyAndReadOperation = replaceConcurrenlyAndRead { + id "replace-concurrent-and-read-id" + partitionKey "pk" + update (fun item -> async { return Result.Ok item }) + } + + let updateResult = + replaceConcurrentlyOperation.Update { id = "id"; partitionKey = "pk"; value = 2 } + |> Async.RunSynchronously + + Assert.IsTrue (replaceConcurrentlyOperation.Id = "replace-concurrent-id", "Replace concurrently builder should set id.") + Assert.IsOk (updateResult, "Replace concurrently builder should set update function.") + Assert.IsFalse ( + replaceConcurrentlyOperation.RequestOptions.EnableContentResponseOnWrite, + "Replace concurrently builder should disable content response." + ) + Assert.IsTrue ( + replaceConcurrentlyAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "Replace concurrently and read builder should enable content response." + ) + + [] + member _.``Upsert builders configure operation and content response mode`` () = + let upsertItem = { id = "upsert-id"; partitionKey = "pk"; value = 1 } + + let upsertOperation = upsert { + item upsertItem + partitionKey upsertItem.partitionKey + eTag "upsert-etag" + } + + let upsertAndReadOperation = upsertAndRead { + item upsertItem + partitionKey upsertItem.partitionKey + } + + Assert.IsValueSome (upsertOperation.PartitionKey, "Upsert builder should set partition key.") + Assert.IsTrue (upsertOperation.RequestOptions.IfMatchEtag = "upsert-etag", "Upsert builder should set eTag.") + Assert.IsFalse ( + upsertOperation.RequestOptions.EnableContentResponseOnWrite, + "Upsert builder should disable content response." + ) + Assert.IsTrue ( + upsertAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "UpsertAndRead builder should enable content response." + ) + + [] + member _.``Upsert concurrently builders configure updateOrCreate and response mode`` () = + let upsertConcurrentlyOperation = upsertConcurrenly { + id "upsert-concurrent-id" + partitionKey "pk" + updateOrCreate (fun maybeItem -> async { + match maybeItem with + | Some item -> return Result.Ok { item with value = item.value + 1 } + | None -> return Result.Ok { id = "new-id"; partitionKey = "pk"; value = 1 } + }) + } + + let upsertConcurrentlyAndReadOperation = upsertConcurrenlyAndRead { + id "upsert-concurrent-and-read-id" + partitionKey "pk" + updateOrCreate (fun _ -> async { return Error "custom-error" }) + } + + let updateResult = + upsertConcurrentlyOperation.UpdateOrCreate None + |> Async.RunSynchronously + + Assert.IsTrue (upsertConcurrentlyOperation.Id = "upsert-concurrent-id", "Upsert concurrently builder should set id.") + Assert.IsOk (updateResult, "Upsert concurrently builder should set updateOrCreate function.") + Assert.IsFalse ( + upsertConcurrentlyOperation.RequestOptions.EnableContentResponseOnWrite, + "Upsert concurrently builder should disable content response." + ) + Assert.IsTrue ( + upsertConcurrentlyAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "Upsert concurrently and read builder should enable content response." + ) + + [] + member _.``Patch builders configure operations and content response mode`` () = + let patchOperation = patch { + id "patch-id" + partitionKey "pk" + operation (PatchOperation.Replace ("/value", 2)) + filterPredicate "FROM c WHERE c.partitionKey = 'pk'" + } + + let patchAndReadOperation = patchAndRead { + id "patch-and-read-id" + partitionKey "pk" + operation (PatchOperation.Replace ("/value", 5)) + } + + Assert.IsTrue (patchOperation.Id = "patch-id", "Patch builder should set id.") + Assert.IsTrue (patchOperation.Operations.Length = 1, "Patch builder should collect operations.") + Assert.IsTrue ( + patchOperation.RequestOptions.FilterPredicate = "FROM c WHERE c.partitionKey = 'pk'", + "Patch builder should set filter predicate." + ) + Assert.IsFalse ( + patchOperation.RequestOptions.EnableContentResponseOnWrite, + "Patch builder should disable content response." + ) + Assert.IsTrue ( + patchAndReadOperation.RequestOptions.EnableContentResponseOnWrite, + "PatchAndRead builder should enable content response." + ) + + [] + member _.``Delete builder configures id partition key and request options`` () = + let operation = delete { + id "delete-id" + partitionKey "pk" + eTag "delete-etag" + sessionToken "delete-session" + } + + Assert.IsTrue (operation.Id = "delete-id", "Delete builder should set id.") + let options = + Assert.WantValueSome (operation.RequestOptions, "Delete builder should initialize request options.") + Assert.IsTrue (options.IfNoneMatchEtag = "delete-etag", "Delete builder should set eTag.") + Assert.IsTrue (options.SessionToken = "delete-session", "Delete builder should set session token.") + + [] + member _.``Unique key builders configure key and policy paths`` () = + let uniqueKeyDefinition = uniqueKey { paths [ "/tenantId"; "/email" ] } + let policy = uniqueKeyPolicy { key uniqueKeyDefinition } + + Assert.IsTrue (uniqueKeyDefinition.Paths.Count = 2, "UniqueKey builder should add all paths.") + Assert.IsTrue (policy.UniqueKeys.Count = 1, "UniqueKeyPolicy builder should add unique key.") diff --git a/tests/Cosmos.Tests/CosmosAssert.fs b/tests/Cosmos.Tests/CosmosAssert.fs new file mode 100644 index 0000000..96b1edd --- /dev/null +++ b/tests/Cosmos.Tests/CosmosAssert.fs @@ -0,0 +1,95 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System +open System.Diagnostics +open System.Net +open System.Runtime.InteropServices +open FSharp.Azure.Cosmos.Create +open FSharp.Azure.Cosmos.Delete +open FSharp.Azure.Cosmos.Read +open FSharp.Azure.Cosmos.Replace +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type CosmosAssert private () = + + static member private GetMessageOrDefault (message : string) (defaultMessage : string) = + if String.IsNullOrWhiteSpace message then + defaultMessage + else + message + + static member WantOk<'T> (response : ItemResponse<'T>, [] message) = + match response.StatusCode with + | HttpStatusCode.OK + | HttpStatusCode.Created -> response.Resource + | _ -> + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected OK or Created but got {response.StatusCode}.") + Unchecked.defaultof<_> + + static member IsOk (response : ItemResponse<'T>, [] message) = CosmosAssert.WantOk (response, message) |> ignore + + static member WantOk<'T> (result : CreateResult<'T>, [] message) = + match result with + | CreateResult.Ok ok -> ok + | _ -> + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected CreateResult.Ok but got {result}.") + Unchecked.defaultof<_> + + static member IsOk (result : CreateResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore + + static member WantOk<'T> (result : ReadResult<'T>, [] message) = + match result with + | ReadResult.Ok ok -> ok + | _ -> + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected ReadResult.Ok but got {result}.") + Unchecked.defaultof<_> + + static member IsOk (result : ReadResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore + + static member WantOk<'T> (result : ReplaceResult<'T>, [] message) = + match result with + | ReplaceResult.Ok ok -> ok + | _ -> + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected ReplaceResult.Ok but got {result}.") + Unchecked.defaultof<_> + + static member IsOk (result : ReplaceResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore + + static member WantOk<'T> (result : DeleteResult<'T>, [] message) = + match result with + | DeleteResult.Ok ok -> ok + | _ -> + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected DeleteResult.Ok but got {result}.") + Unchecked.defaultof<_> + + static member IsOk (result : DeleteResult<'T>, [] message) = CosmosAssert.WantOk (result, message) |> ignore + + static member WantNotFound<'T> (result : ReadResult<'T>, [] message) = + match result with + | ReadResult.NotFound response -> response + | _ -> + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected ReadResult.NotFound but got {result}.") + Unchecked.defaultof<_> + + static member IsNotFound (result : ReadResult<'T>, [] message) = + CosmosAssert.WantNotFound (result, message) |> ignore + + static member WantNotFound<'T> (result : DeleteResult<'T>, [] message) = + match result with + | DeleteResult.NotFound response -> response + | _ -> + Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected DeleteResult.NotFound but got {result}.") + Unchecked.defaultof<_> + + static member IsNotFound (result : DeleteResult<'T>, [] message) = + CosmosAssert.WantNotFound (result, message) |> ignore + + static member WantConflict (result : CreateResult<'T>, [] message) = + match result with + | CreateResult.IdAlreadyExists _ -> () + | _ -> Assert.Fail (CosmosAssert.GetMessageOrDefault message $"Expected CreateResult.IdAlreadyExists but got {result}.") + + static member IsConflict (result : CreateResult<'T>, [] message) = + CosmosAssert.WantConflict (result, message) |> ignore diff --git a/tests/Cosmos.Tests/CreateOperationTests.fs b/tests/Cosmos.Tests/CreateOperationTests.fs new file mode 100644 index 0000000..143541b --- /dev/null +++ b/tests/Cosmos.Tests/CreateOperationTests.fs @@ -0,0 +1,61 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type CreateOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.``Create execute persists item`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "create" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Create should return CreateResult.Ok.") + Assert.IsTrue (createResponse.HttpStatusCode = HttpStatusCode.Created, "Create should return HTTP 201.") + + let! readResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let created = CosmosAssert.WantOk (readResponse.Result, "Created item should be readable.") + Assert.IsTrue (testItem.id = created.id, "Create should persist item id.") + Assert.IsTrue (testItem.partitionKey = created.partitionKey, "Create should persist partition key.") + } + + [] + member this.``CreateAndRead execute returns created resource`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "create-and-read" + + let! response = + container.ExecuteAsync ( + createAndRead { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let created = CosmosAssert.WantOk (response.Result, "CreateAndRead should return CreateResult.Ok.") + Assert.IsTrue (testItem.id = created.id, "CreateAndRead should return created item id.") + Assert.IsTrue (testItem.partitionKey = created.partitionKey, "CreateAndRead should return created partition key.") + } diff --git a/tests/Cosmos.Tests/DeleteOperationTests.fs b/tests/Cosmos.Tests/DeleteOperationTests.fs new file mode 100644 index 0000000..ed22f8e --- /dev/null +++ b/tests/Cosmos.Tests/DeleteOperationTests.fs @@ -0,0 +1,52 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type DeleteOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.``Delete execute removes item and subsequent read is not found`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "delete" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let! deleteResponse = + container.ExecuteAsync ( + delete { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (deleteResponse.Result, "Delete should return DeleteResult.Ok.") + Assert.IsTrue (deleteResponse.HttpStatusCode = HttpStatusCode.NoContent, "Delete should return HTTP 204.") + + let! missingResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsNotFound (missingResponse.Result, "Read after delete should return ReadResult.NotFound.") + Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read after delete should return HTTP 404.") + } diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index 9588d7d..e186657 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -17,7 +17,21 @@ - + + + + + + + + + + + + + + + diff --git a/tests/Cosmos.Tests/IntegrationInfrastructure.fs b/tests/Cosmos.Tests/IntegrationInfrastructure.fs new file mode 100644 index 0000000..31d1c10 --- /dev/null +++ b/tests/Cosmos.Tests/IntegrationInfrastructure.fs @@ -0,0 +1,165 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System +open System.Net +open System.Net.Http +open System.Net.Security +open System.Threading +open System.Threading.Tasks + +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +module TestContextExtensions = + + type TestContext with + + member ctx.GetTestDatabaseIdentifier () = + match ctx.TestData with + | null -> ctx.TestName + | testData -> + let dataHash = + testData + |> Array.fold + (fun acc item -> + let itemHash = + match item with + | null -> 0 + | item -> item.GetHashCode () + + HashCode.Combine (acc, itemHash) + ) + 0 + |> int64 + |> abs + + $"{ctx.TestName}_{dataHash}" + +[] +type TestBase () = + + member val TestContext = Unchecked.defaultof with get, set + + member this.CancellationToken = this.TestContext.CancellationTokenSource.Token + +type DatabaseTestApplicationFactory (testContext : TestContext) = + [] + let endpoint = "https://127.0.0.1:8081" + + [] + let primaryKey = + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" + + let buildDatabaseId () = testContext.GetTestDatabaseIdentifier () + + let databaseId = buildDatabaseId () + + let isLocalEmulatorHost (uri : Uri) = + uri.Host.Equals ("localhost", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals ("127.0.0.1", StringComparison.OrdinalIgnoreCase) + + let createHttpClient () = + let handler = new HttpClientHandler () + + handler.ServerCertificateCustomValidationCallback <- + (fun request _ _ errors -> + match request.RequestUri with + | null -> errors = SslPolicyErrors.None + | requestUri when errors = SslPolicyErrors.None -> true + | requestUri -> isLocalEmulatorHost requestUri + ) + + new HttpClient (handler, true) + + let client = + new CosmosClient ( + endpoint, + primaryKey, + CosmosClientOptions (ConnectionMode = ConnectionMode.Gateway, HttpClientFactory = Func createHttpClient) + ) + let mutable database = ValueNone + + member _.Client = client + member _.DatabaseId = databaseId + member _.Database = database + + member _.InitializeAsync (cancellationToken : CancellationToken) : Task = task { + let! createdDatabase = client.CreateDatabaseIfNotExistsAsync (databaseId, cancellationToken = cancellationToken) + database <- ValueSome createdDatabase.Database + } + + member _.CleanupAsync (cancellationToken : CancellationToken) : Task = task { + match database with + | ValueNone -> () + | ValueSome existingDatabase -> + let! _ = existingDatabase.DeleteAsync (cancellationToken = cancellationToken) + database <- ValueNone + } + + member _.GetOrCreateContainerAsync + (containerId : string, partitionKeyPath : string, cancellationToken : CancellationToken) + : Task + = + task { + let database = + match database with + | ValueSome existingDatabase -> existingDatabase + | ValueNone -> invalidOp "Database is not initialized." + + let! containerResponse = + database.CreateContainerIfNotExistsAsync ( + ContainerProperties (containerId, partitionKeyPath), + cancellationToken = cancellationToken + ) + + return containerResponse.Container + } + + abstract SeedDataAsync : cancellationToken : CancellationToken -> Task + default _.SeedDataAsync (cancellationToken : CancellationToken) = Task.CompletedTask + + interface IAsyncDisposable with + member this.DisposeAsync () = + task { + do! this.CleanupAsync (CancellationToken.None) + client.Dispose () + } + |> ValueTask + +[] +type IntegrationTestBase<'DatabaseTestApplicationFactory when 'DatabaseTestApplicationFactory :> DatabaseTestApplicationFactory> + () + = + inherit TestBase () + + member val private application : 'DatabaseTestApplicationFactory voption = ValueNone with get, set + + member this.Application = + match this.application with + | ValueNone -> invalidOp "Application not initialized. Ensure test runs within TestInitialize/TestCleanup lifecycle." + | ValueSome application -> application + + abstract CreateApplication : TestContext -> 'DatabaseTestApplicationFactory + + [] + member this.Initialize () : Task = task { + let application = this.CreateApplication (this.TestContext) + this.application <- ValueSome application + do! application.InitializeAsync (this.CancellationToken) + do! application.SeedDataAsync (this.CancellationToken) + } + + [] + member this.Cleanup () : Task = task { + match this.application with + | ValueNone -> () + | ValueSome application -> + do! (application :> IAsyncDisposable).DisposeAsync().AsTask () + this.application <- ValueNone + } + +type IntegrationTestBase () = + inherit IntegrationTestBase () + + override _.CreateApplication context = DatabaseTestApplicationFactory (context) diff --git a/tests/Cosmos.Tests/IterationExtensionsTests.fs b/tests/Cosmos.Tests/IterationExtensionsTests.fs new file mode 100644 index 0000000..0dd7316 --- /dev/null +++ b/tests/Cosmos.Tests/IterationExtensionsTests.fs @@ -0,0 +1,61 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System.Threading.Tasks +open FSharp.Control +open FSharp.Azure.Cosmos.Tests +open Microsoft.Azure.Cosmos +open Microsoft.Azure.Cosmos.Linq +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type IterationExtensionsIntegrationTests () = + inherit OperationTestBase () + + override _.CreateApplication context = MultipleItemsScenario (context) + + [] + member this.``FeedIterator AsAsyncEnumerable iterates seeded items`` () : Task = task { + let! container = this.GetContainer () + let firstItem, secondItem = + match this.Application.SeededItems with + | [ firstItem; secondItem ] -> firstItem, secondItem + | seededItems -> failwith $"Expected exactly two seeded items but got {seededItems.Length}." + + let query = + QueryDefinition("SELECT * FROM c WHERE c.partitionKey = @partitionKey").WithParameter ("@partitionKey", "integration") + + let iterator = container.GetItemQueryIterator (query) + let expectedIds = set [ firstItem.id; secondItem.id ] + let! iteratedItems = + iterator.AsAsyncEnumerable (this.CancellationToken) + |> TaskSeq.toListAsync + let foundCount = + iteratedItems + |> Seq.filter (fun item -> expectedIds.Contains item.id) + |> Seq.length + Assert.IsTrue ((foundCount = 2), "FeedIterator.AsAsyncEnumerable should iterate seeded items.") + } + + [] + member this.``IQueryable AsAsyncEnumerable iterates seeded items`` () : Task = task { + let! container = this.GetContainer () + let firstItem, secondItem = + match this.Application.SeededItems with + | [ firstItem; secondItem ] -> firstItem, secondItem + | seededItems -> failwith $"Expected exactly two seeded items but got {seededItems.Length}." + + let queryable = + container.GetItemLinqQueryable ( + requestOptions = QueryRequestOptions (PartitionKey = PartitionKey "integration") + ) + + let expectedIds = set [ firstItem.id; secondItem.id ] + let! iteratedItems = + queryable.AsAsyncEnumerable (this.CancellationToken) + |> TaskSeq.toListAsync + let foundCount = + iteratedItems + |> Seq.filter (fun item -> expectedIds.Contains item.id) + |> Seq.length + Assert.IsTrue ((foundCount = 2), "IQueryable.AsAsyncEnumerable should iterate seeded items.") + } diff --git a/tests/Cosmos.Tests/OperationTestInfrastructure.fs b/tests/Cosmos.Tests/OperationTestInfrastructure.fs new file mode 100644 index 0000000..3478351 --- /dev/null +++ b/tests/Cosmos.Tests/OperationTestInfrastructure.fs @@ -0,0 +1,47 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open Microsoft.Azure.Cosmos + +[] +type TestItem = { id : string; partitionKey : string; name : string; quantity : int } + +[] +type OperationTestBase<'DatabaseTestApplicationFactory when 'DatabaseTestApplicationFactory :> DatabaseTestApplicationFactory> () + = + inherit IntegrationTestBase<'DatabaseTestApplicationFactory> () + + let containerId = "operation-tests" + + member this.GetContainer () : Task = task { + return! this.Application.GetOrCreateContainerAsync (containerId, "/partitionKey", this.CancellationToken) + } + + member internal this.NewItem (suffix : string) : TestItem = { + id = $"{this.TestContext.TestName}-{suffix}" + partitionKey = "integration" + name = $"item-{suffix}" + quantity = 1 + } + + member internal this.SeedItemsAsync (container : Container, items : TestItem seq) : Task = task { + for seedItem in items do + let! createResponse = + container.ExecuteAsync ( + create { + item seedItem + partitionKey seedItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, $"Seed create should succeed for item '{seedItem.id}'.") + } + +[] +type OperationTestBase () = + inherit OperationTestBase () + + override _.CreateApplication context = DatabaseTestApplicationFactory (context) diff --git a/tests/Cosmos.Tests/PatchOperationTests.fs b/tests/Cosmos.Tests/PatchOperationTests.fs new file mode 100644 index 0000000..f7d907a --- /dev/null +++ b/tests/Cosmos.Tests/PatchOperationTests.fs @@ -0,0 +1,102 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type PatchOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.``Patch execute overwrite updates item`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "patch" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let patchedName = "item-patched" + let patchedQuantity = 9 + + let! patchResponse = + container.ExecuteOverwriteAsync ( + patch { + id testItem.id + partitionKey testItem.partitionKey + operation (PatchOperation.Replace ("/name", patchedName)) + operation (PatchOperation.Replace ("/quantity", patchedQuantity)) + }, + this.CancellationToken + ) + + match patchResponse.Result with + | PatchResult.Ok _ -> Assert.IsTrue (patchResponse.HttpStatusCode = HttpStatusCode.OK, "Patch should return HTTP 200.") + | result -> Assert.Fail ($"Expected patch success, got {result}.") + + let! readResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let persisted = CosmosAssert.WantOk (readResponse.Result, "Patched item should be readable.") + let isNamePatched = patchedName = persisted.name + let isQuantityPatched = patchedQuantity = persisted.quantity + Assert.IsTrue (isNamePatched, "Patch should persist patched name.") + Assert.IsTrue (isQuantityPatched, "Patch should persist patched quantity.") + } + + [] + member this.``PatchAndRead execute overwrite returns updated item`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "patch-and-read" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let patchedName = "item-patched-and-read" + let patchedQuantity = 11 + + let! patchResponse = + container.ExecuteOverwriteAsync ( + patchAndRead { + id testItem.id + partitionKey testItem.partitionKey + operation (PatchOperation.Replace ("/name", patchedName)) + operation (PatchOperation.Replace ("/quantity", patchedQuantity)) + }, + this.CancellationToken + ) + + match patchResponse.Result with + | PatchResult.Ok patched -> + let isNamePatched = patchedName = patched.name + let isQuantityPatched = patchedQuantity = patched.quantity + Assert.IsTrue (isNamePatched, "PatchAndRead should return patched name.") + Assert.IsTrue (isQuantityPatched, "PatchAndRead should return patched quantity.") + Assert.IsTrue (patchResponse.HttpStatusCode = HttpStatusCode.OK, "PatchAndRead should return HTTP 200.") + | result -> Assert.Fail ($"Expected patchAndRead success, got {result}.") + } diff --git a/tests/Cosmos.Tests/ReadExtensionsTests.fs b/tests/Cosmos.Tests/ReadExtensionsTests.fs new file mode 100644 index 0000000..829ac4a --- /dev/null +++ b/tests/Cosmos.Tests/ReadExtensionsTests.fs @@ -0,0 +1,164 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System.Net +open System +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type ReadExtensionsIntegrationTests () = + inherit OperationTestBase () + + [] + member this.``CountAsync and LongCountAsync return seeded item counts`` () : Task = task { + let! container = this.GetContainer () + let seededItems = [ this.NewItem "count-1"; this.NewItem "count-2"; this.NewItem "count-3" ] + do! this.SeedItemsAsync (container, seededItems) + + let! countByPartition = container.CountAsync ("integration", cancellationToken = this.CancellationToken) + let! countByQuery = container.CountAsync (QueryRequestOptions (), cancellationToken = this.CancellationToken) + let! longCountByPartition = + container.LongCountAsync (PartitionKey "integration", cancellationToken = this.CancellationToken) + + Assert.IsTrue ((countByPartition = 3), "CountAsync by partition should return seeded item count.") + Assert.IsTrue ((countByQuery = 3), "CountAsync by query options should return seeded item count.") + Assert.IsTrue ((longCountByPartition = 3L), "LongCountAsync should return seeded item count.") + } + + [] + member this.``ExistsAsync and IsNotDeletedAsync return expected values`` () : Task = task { + let! container = this.GetContainer () + let firstItem = this.NewItem "exists-1" + let secondItem = this.NewItem "exists-2" + do! this.SeedItemsAsync (container, [ firstItem; secondItem ]) + + let! existsWithPartition = + container.ExistsAsync (firstItem.id, PartitionKey firstItem.partitionKey, this.CancellationToken) + + let! existsWithoutPartition = container.ExistsAsync (firstItem.id, cancellationToken = this.CancellationToken) + + let! missingExists = container.ExistsAsync ($"{firstItem.id}-missing", cancellationToken = this.CancellationToken) + + Assert.IsTrue (existsWithPartition, "ExistsAsync with partition key should return true for existing item.") + Assert.IsTrue (existsWithoutPartition, "ExistsAsync without partition key should return true for existing item.") + Assert.IsFalse (missingExists, "ExistsAsync should return false for missing item.") + + let! notDeletedBeforePatch = container.IsNotDeletedAsync "deletedAt" secondItem.id + + Assert.IsTrue (notDeletedBeforePatch, "IsNotDeletedAsync should return true before deleted marker is set.") + + let! notDeletedWithUnderscoreFieldName = container.IsNotDeletedAsync "_deletedAt" secondItem.id + + Assert.IsTrue ( + notDeletedWithUnderscoreFieldName, + "IsNotDeletedAsync should support deleted field names starting with underscore." + ) + + let! notDeletedWithDigit = container.IsNotDeletedAsync "deletedAt1" secondItem.id + + Assert.IsTrue ( + notDeletedWithDigit, + "IsNotDeletedAsync should support deleted field names with digits after the first character." + ) + + let! digitFieldPatchResponse = + container.ExecuteOverwriteAsync ( + patch { + id firstItem.id + partitionKey firstItem.partitionKey + operation (PatchOperation.Set ("/deletedAt1", "2026-05-24T00:00:00Z")) + }, + this.CancellationToken + ) + + match digitFieldPatchResponse.Result with + | PatchResult.Ok _ -> + Assert.IsTrue (digitFieldPatchResponse.HttpStatusCode = HttpStatusCode.OK, "Patch should return HTTP 200.") + | result -> Assert.Fail ($"Expected patch success for digit-field marker, got {result}.") + + let! notDeletedWithDigitAfterPatch = container.IsNotDeletedAsync "deletedAt1" firstItem.id + + Assert.IsFalse ( + notDeletedWithDigitAfterPatch, + "IsNotDeletedAsync should return false when a digit-containing deleted marker field is set." + ) + + let! patchResponse = + container.ExecuteOverwriteAsync ( + patch { + id secondItem.id + partitionKey secondItem.partitionKey + operation (PatchOperation.Set ("/deletedAt", "2026-05-24T00:00:00Z")) + }, + this.CancellationToken + ) + + match patchResponse.Result with + | PatchResult.Ok _ -> Assert.IsTrue (patchResponse.HttpStatusCode = HttpStatusCode.OK, "Patch should return HTTP 200.") + | result -> Assert.Fail ($"Expected patch success, got {result}.") + + let! notDeletedAfterPatch = container.IsNotDeletedAsync "deletedAt" secondItem.id + + Assert.IsFalse (notDeletedAfterPatch, "IsNotDeletedAsync should return false after deleted marker is set.") + } + + [] + member this.``IsNotDeletedAsync throws for invalid deleted field names`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "invalid-deleted-field-name" + let invokeIsNotDeleted (deletedFieldName : string) = + Func (fun () -> task { + let! _ = container.IsNotDeletedAsync deletedFieldName testItem.id + return () + }) + + let! _ = + Assert.ThrowsExactlyAsync ( + Func (fun () -> task { + let! _ = container.IsNotDeletedAsync Unchecked.defaultof testItem.id + return () + }), + "IsNotDeletedAsync should throw ArgumentNullException when deleted field name is null." + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "", + "IsNotDeletedAsync should throw ArgumentException when deleted field name is empty." + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted " ", + "IsNotDeletedAsync should throw ArgumentException when deleted field name is whitespace." + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "1deletedAt", + "IsNotDeletedAsync should throw ArgumentException when deleted field name starts with a digit." + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "1deleted", + "IsNotDeletedAsync should throw ArgumentException for field names that start with a digit." + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "deleted-at", + "IsNotDeletedAsync should throw ArgumentException when deleted field name has unsupported characters." + ) + + let! _ = + Assert.ThrowsExactlyAsync ( + invokeIsNotDeleted "deleted-field", + "IsNotDeletedAsync should throw ArgumentException for field names with hyphens." + ) + + return () + } diff --git a/tests/Cosmos.Tests/ReadManyOperationTests.fs b/tests/Cosmos.Tests/ReadManyOperationTests.fs new file mode 100644 index 0000000..7646a0e --- /dev/null +++ b/tests/Cosmos.Tests/ReadManyOperationTests.fs @@ -0,0 +1,79 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System.Net +open System.Threading +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +type MultipleItemsScenario (testContext : TestContext) as this = + inherit DatabaseTestApplicationFactory (testContext) + + let containerId = "operation-tests" + + let firstSeededItem : TestItem = { + id = $"{testContext.TestName}-readmany-1" + partitionKey = "integration" + name = "item-readmany-1" + quantity = 1 + } + + let secondSeededItem : TestItem = { + id = $"{testContext.TestName}-readmany-2" + partitionKey = "integration" + name = "item-readmany-2" + quantity = 2 + } + + member _.SeededItems = [ firstSeededItem; secondSeededItem ] + + override _.SeedDataAsync (cancellationToken : CancellationToken) : Task = task { + let! container = this.GetOrCreateContainerAsync (containerId, "/partitionKey", cancellationToken) + + for seededItem in this.SeededItems do + let! createResponse = + container.ExecuteAsync ( + create { + item seededItem + partitionKey seededItem.partitionKey + }, + cancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, $"ReadMany scenario seed create should succeed for '{seededItem.id}'.") + } + +[] +type ReadManyOperationIntegrationTests () = + inherit OperationTestBase () + + override _.CreateApplication context = MultipleItemsScenario (context) + + [] + member this.``ReadMany execute returns matching items`` () : Task = task { + let! container = this.GetContainer () + let firstItem, secondItem = + match this.Application.SeededItems with + | [ firstItem; secondItem ] -> firstItem, secondItem + | seededItems -> failwith $"Expected exactly two seeded items but got {seededItems.Length}." + + let! readManyResponse = + container.ExecuteAsync ( + readMany { + item firstItem.id firstItem.partitionKey + item secondItem.id secondItem.partitionKey + }, + this.CancellationToken + ) + + match readManyResponse.Result with + | ReadManyResult.Ok (feed : FeedResponse) -> + let returnedIds = feed |> Seq.map _.id |> Set.ofSeq + Assert.IsTrue (feed.Count = 2, "ReadMany should return requested number of items.") + Assert.IsTrue (returnedIds.Contains firstItem.id, "ReadMany should include first item.") + Assert.IsTrue (returnedIds.Contains secondItem.id, "ReadMany should include second item.") + Assert.IsTrue (readManyResponse.HttpStatusCode = HttpStatusCode.OK, "ReadMany should return HTTP 200.") + | result -> Assert.Fail ($"Expected read many success, got {result}.") + } diff --git a/tests/Cosmos.Tests/ReadOperationTests.fs b/tests/Cosmos.Tests/ReadOperationTests.fs new file mode 100644 index 0000000..a758825 --- /dev/null +++ b/tests/Cosmos.Tests/ReadOperationTests.fs @@ -0,0 +1,75 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System.Net +open System.Threading +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests +open Microsoft.VisualStudio.TestTools.UnitTesting + +type SingleItemScenario (testContext : TestContext) as this = + inherit DatabaseTestApplicationFactory (testContext) + + let containerId = "operation-tests" + + let seededItem : TestItem = { + id = $"{testContext.TestName}-read" + partitionKey = "integration" + name = "item-read" + quantity = 1 + } + + member _.SeededItem = seededItem + + override _.SeedDataAsync (cancellationToken : CancellationToken) : Task = task { + let! container = this.GetOrCreateContainerAsync (containerId, "/partitionKey", cancellationToken) + + let! createResponse = + container.ExecuteAsync ( + create { + item seededItem + partitionKey seededItem.partitionKey + }, + cancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Read scenario seed create should succeed.") + } + +[] +type ReadOperationIntegrationTests () = + inherit OperationTestBase () + + override _.CreateApplication context = SingleItemScenario (context) + + [] + member this.``Read execute returns existing and not found states`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.Application.SeededItem + + let! foundResponse = + container.ExecuteAsync ( + read { + id testItem.id + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + let found = + CosmosAssert.WantOk (foundResponse.Result, "Read should return ReadResult.Ok for existing item.") + Assert.IsTrue (testItem.id = found.id, "Read should return created item.") + Assert.IsTrue (foundResponse.HttpStatusCode = HttpStatusCode.OK, "Read should return HTTP 200 for existing item.") + + let! missingResponse = + container.ExecuteAsync ( + read { + id $"{testItem.id}-missing" + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsNotFound (missingResponse.Result, "Read should return ReadResult.NotFound for missing item.") + Assert.IsTrue (missingResponse.HttpStatusCode = HttpStatusCode.NotFound, "Read missing should return HTTP 404.") + } diff --git a/tests/Cosmos.Tests/ReplaceOperationTests.fs b/tests/Cosmos.Tests/ReplaceOperationTests.fs new file mode 100644 index 0000000..aafc6df --- /dev/null +++ b/tests/Cosmos.Tests/ReplaceOperationTests.fs @@ -0,0 +1,150 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type ReplaceOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.``Replace execute overwrite replaces existing item`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "replace" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let replacement = { testItem with name = "item-replaced"; quantity = 3 } + + let! replaceResponse = + container.ExecuteOverwriteAsync ( + replace { + id replacement.id + item replacement + partitionKey replacement.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (replaceResponse.Result, "Replace should return ReplaceResult.Ok.") + Assert.IsTrue (replaceResponse.HttpStatusCode = HttpStatusCode.OK, "Replace should return HTTP 200.") + + let! readResponse = + container.ExecuteAsync ( + read { + id replacement.id + partitionKey replacement.partitionKey + }, + this.CancellationToken + ) + + let persisted = CosmosAssert.WantOk (readResponse.Result, "Replaced item should be readable.") + Assert.IsTrue (replacement.name = persisted.name, "Replace should persist replacement name.") + Assert.IsTrue (replacement.quantity = persisted.quantity, "Replace should persist replacement quantity.") + } + + [] + member this.``ReplaceAndRead execute overwrite returns replaced item`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "replace-and-read" + + let! createResponse = + container.ExecuteAsync ( + create { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let replacement = { testItem with name = "item-replaced-and-read"; quantity = 6 } + + let! replaceResponse = + container.ExecuteOverwriteAsync ( + replaceAndRead { + id replacement.id + item replacement + partitionKey replacement.partitionKey + }, + this.CancellationToken + ) + + let replaced = + CosmosAssert.WantOk (replaceResponse.Result, "ReplaceAndRead should return ReplaceResult.Ok.") + Assert.IsTrue (replacement.name = replaced.name, "ReplaceAndRead should return replacement name.") + Assert.IsTrue (replacement.quantity = replaced.quantity, "ReplaceAndRead should return replacement quantity.") + Assert.IsTrue (replaceResponse.HttpStatusCode = HttpStatusCode.OK, "ReplaceAndRead should return HTTP 200.") + } + + [] + member this.``Replace concurrently retries and applies update`` () : Task = task { + let! container = this.GetContainer () + let original = this.NewItem "replace-concurrent" + + let! createResponse = + container.ExecuteAsync ( + create { + item original + partitionKey original.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let mutable conflictInjected = false + + let operation = replaceConcurrenly { + id original.id + partitionKey original.partitionKey + update (fun current -> async { + if not conflictInjected then + conflictInjected <- true + + let competingUpdate = { current with name = "competing-update" } + + let! _ = + container.ExecuteOverwriteAsync ( + replace { + id competingUpdate.id + item competingUpdate + partitionKey competingUpdate.partitionKey + }, + this.CancellationToken + ) + |> Async.AwaitTask + + () + + return + Result.Ok { + current with + name = "replace-concurrent-updated" + quantity = current.quantity + 10 + } + }) + } + + let! concurrentResponse = container.ExecuteConcurrentlyAsync (operation, 3, this.CancellationToken) + + match concurrentResponse.Result with + | ReplaceConcurrentResult.Ok updated -> + Assert.IsTrue (conflictInjected, "Replace concurrently test should inject a conflicting update at least once.") + Assert.IsTrue (updated.name = "replace-concurrent-updated", "Replace concurrently should persist updated name.") + Assert.IsTrue (updated.quantity = original.quantity + 10, "Replace concurrently should persist updated quantity.") + | result -> Assert.Fail ($"Expected replace concurrently success after retry, got {result}.") + } diff --git a/tests/Cosmos.Tests/TestCategories.fs b/tests/Cosmos.Tests/TestCategories.fs new file mode 100644 index 0000000..6ee0c07 --- /dev/null +++ b/tests/Cosmos.Tests/TestCategories.fs @@ -0,0 +1,33 @@ +namespace FSharp.Azure.Cosmos.Tests + +[] +module TestCategories = + [] + let Builders = "Builders" + + [] + let Create = "Create" + + [] + let Read = "Read" + + [] + let ReadMany = "ReadMany" + + [] + let Upsert = "Upsert" + + [] + let Replace = "Replace" + + [] + let Patch = "Patch" + + [] + let Delete = "Delete" + + [] + let ReadExtensions = "ReadExtensions" + + [] + let IterationExtensions = "IterationExtensions" diff --git a/tests/Cosmos.Tests/Tests.fs b/tests/Cosmos.Tests/Tests.fs deleted file mode 100644 index 7afcb8b..0000000 --- a/tests/Cosmos.Tests/Tests.fs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Tests - -open System -open Microsoft.VisualStudio.TestTools.UnitTesting - -[] -type TestClass () = - - [] - member this.TestMethodPassing () = Assert.IsTrue (true) diff --git a/tests/Cosmos.Tests/UpsertOperationTests.fs b/tests/Cosmos.Tests/UpsertOperationTests.fs new file mode 100644 index 0000000..791f889 --- /dev/null +++ b/tests/Cosmos.Tests/UpsertOperationTests.fs @@ -0,0 +1,163 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System.Net +open System.Threading.Tasks +open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests +open Microsoft.VisualStudio.TestTools.UnitTesting + +[] +type UpsertOperationIntegrationTests () = + inherit OperationTestBase () + + [] + member this.``Upsert execute overwrite creates then updates item`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "upsert" + + let! createResult = + container.ExecuteOverwriteAsync ( + upsert { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match createResult.Result with + | UpsertResult.Ok _ -> + Assert.IsTrue (createResult.HttpStatusCode = HttpStatusCode.Created, "First upsert should create item (HTTP 201).") + | result -> Assert.Fail ($"Expected first upsert success, got {result}.") + + let updated = { testItem with name = "item-upsert-updated"; quantity = 5 } + + let! updateResult = + container.ExecuteOverwriteAsync ( + upsert { + item updated + partitionKey updated.partitionKey + }, + this.CancellationToken + ) + + match updateResult.Result with + | UpsertResult.Ok _ -> + Assert.IsTrue (updateResult.HttpStatusCode = HttpStatusCode.OK, "Second upsert should update item (HTTP 200).") + | result -> Assert.Fail ($"Expected second upsert success, got {result}.") + + let! readResponse = + container.ExecuteAsync ( + read { + id updated.id + partitionKey updated.partitionKey + }, + this.CancellationToken + ) + + let persisted = CosmosAssert.WantOk (readResponse.Result, "Updated upsert item should be readable.") + Assert.IsTrue (updated.name = persisted.name, "Upsert should persist updated name.") + Assert.IsTrue (updated.quantity = persisted.quantity, "Upsert should persist updated quantity.") + } + + [] + member this.``UpsertAndRead execute overwrite returns updated item`` () : Task = task { + let! container = this.GetContainer () + let testItem = this.NewItem "upsert-and-read" + + let! createdResponse = + container.ExecuteOverwriteAsync ( + upsertAndRead { + item testItem + partitionKey testItem.partitionKey + }, + this.CancellationToken + ) + + match createdResponse.Result with + | UpsertResult.Ok created -> + Assert.IsTrue (testItem.name = created.name, "UpsertAndRead create should return created resource.") + Assert.IsTrue ( + createdResponse.HttpStatusCode = HttpStatusCode.Created, + "UpsertAndRead create should return HTTP 201." + ) + | result -> Assert.Fail ($"Expected upsertAndRead create success, got {result}.") + + let updated = { testItem with name = "item-upsert-and-read-updated"; quantity = 9 } + + let! updatedResponse = + container.ExecuteOverwriteAsync ( + upsertAndRead { + item updated + partitionKey updated.partitionKey + }, + this.CancellationToken + ) + + match updatedResponse.Result with + | UpsertResult.Ok upserted -> + Assert.IsTrue (updated.name = upserted.name, "UpsertAndRead update should return updated name.") + Assert.IsTrue (updated.quantity = upserted.quantity, "UpsertAndRead update should return updated quantity.") + Assert.IsTrue (updatedResponse.HttpStatusCode = HttpStatusCode.OK, "UpsertAndRead update should return HTTP 200.") + | result -> Assert.Fail ($"Expected upsertAndRead update success, got {result}.") + } + + [] + member this.``Upsert concurrently retries and applies update`` () : Task = task { + let! container = this.GetContainer () + let original = this.NewItem "upsert-concurrent" + + let! createResponse = + container.ExecuteAsync ( + create { + item original + partitionKey original.partitionKey + }, + this.CancellationToken + ) + + CosmosAssert.IsOk (createResponse.Result, "Seed create should succeed.") + + let mutable conflictInjected = false + + let operation = upsertConcurrenly { + id original.id + partitionKey original.partitionKey + updateOrCreate (fun maybeCurrent -> async { + match maybeCurrent with + | Some current -> + if not conflictInjected then + conflictInjected <- true + + let competingUpdate = { current with name = "competing-upsert-update" } + + let! _ = + container.ExecuteOverwriteAsync ( + upsert { + item competingUpdate + partitionKey competingUpdate.partitionKey + }, + this.CancellationToken + ) + |> Async.AwaitTask + + () + + return + Result.Ok { + current with + name = "upsert-concurrent-updated" + quantity = current.quantity + 7 + } + | None -> return Result.Error "Expected existing item for concurrent upsert test." + }) + } + + let! concurrentResponse = container.ExecuteConcurrentlyAsync (operation, 3, this.CancellationToken) + + match concurrentResponse.Result with + | UpsertConcurrentResult.Ok updated -> + Assert.IsTrue (conflictInjected, "Upsert concurrently test should inject a conflicting update at least once.") + Assert.IsTrue (updated.name = "upsert-concurrent-updated", "Upsert concurrently should persist updated name.") + Assert.IsTrue (updated.quantity = original.quantity + 7, "Upsert concurrently should persist updated quantity.") + | result -> Assert.Fail ($"Expected upsert concurrently success after retry, got {result}.") + }