diff options
Diffstat (limited to 'tests/FundLab.Api.Tests/OrderTests.fs')
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 400 |
1 files changed, 400 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs new file mode 100644 index 0000000..5e6771c --- /dev/null +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -0,0 +1,400 @@ +namespace FundLab.Api.Tests + +open System +open System.Collections.Concurrent +open System.Net.Http +open System.Text.Json +open System.Threading.Tasks +open FundLab.Api +open Xunit + +[<Collection("postgres")>] +type OrderTests(fixture: PostgresFixture) = + let repository () = + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let seedInstrument () = + let code = Random.Shared.Next(0, 1000000).ToString("D6") + let payload = + { + Source = "akshare" + SourceRevision = "akshare-test/eastmoney" + CollectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero) + Instruments = [ { Code = code; Name = "订单测试基金"; FundType = None } ] + } + + repository().UpsertInstruments(payload, "order-test-hash") + code + + let createFund (initialCash: decimal) = + let command = + { + Name = "订单测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "order-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app () = App.createApplication (repository ()) + + let orderBody fundCode amount fee = + sprintf "{\"fundCode\":\"%s\",\"amount\":\"%s\",\"feeAmount\":\"%s\"}" fundCode amount fee + + let postOrder (api) fundId idempotencyKey body = + PersistenceTestHelpers.invoke + api + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + body + + let getOrderList (api) fundId = + PersistenceTestHelpers.invoke + api + "GET" + (sprintf "/api/funds/%O/orders" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let getFund (api) fundId = + PersistenceTestHelpers.invoke + api + "GET" + (sprintf "/api/funds/%O" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let jsonProperty (body: string) (name: string) = + use document = JsonDocument.Parse(body) + document.RootElement.GetProperty(name).GetString() + + let orderCount (body: string) = + use document = JsonDocument.Parse(body) + document.RootElement.GetArrayLength() + + [<Fact>] + member _.``subscription order reserves cash and stays visible as pending``() = + let code = seedInstrument () + let fundId = createFund 1000.00m + let api = app () + let key = fixture.Key("order-happy-path") + + let status, body = + postOrder api fundId key (orderBody code "100.00" "1.50") + + Assert.Equal(201, status) + Assert.Contains(sprintf "\"fundCode\":\"%s\"" code, body) + Assert.Contains("\"amount\":\"100.00\"", body) + Assert.Contains("\"feeAmount\":\"1.50\"", body) + Assert.Contains("\"reservedTotal\":\"101.50\"", body) + Assert.Contains("\"status\":\"submitted\"", body) + Assert.Contains(sprintf "\"fundId\":\"%O\"" fundId, body) + + let orderId = PersistenceTestHelpers.responseId body + + let fundStatus, fundBody = getFund api fundId + Assert.Equal(200, fundStatus) + Assert.Contains("\"availableCash\":\"898.50\"", fundBody) + Assert.Contains("\"reservedCash\":\"101.50\"", fundBody) + + let listStatus, listBody = getOrderList api fundId + Assert.Equal(200, listStatus) + Assert.Equal(1, orderCount listBody) + Assert.Contains(sprintf "\"id\":\"%O\"" orderId, listBody) + + [<Fact>] + member _.``subscription order replays the same order for an identical idempotency key``() = + let code = seedInstrument () + let fundId = createFund 500.00m + let api = app () + let key = fixture.Key("order-replay") + let body = orderBody code "50.00" "2.50" + + let createStatus, createResponse = postOrder api fundId key body + Assert.Equal(201, createStatus) + let orderId = PersistenceTestHelpers.responseId createResponse + + let replayStatus, replayResponse = postOrder api fundId key body + Assert.Equal(200, replayStatus) + Assert.Equal(orderId, PersistenceTestHelpers.responseId replayResponse) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"447.50\"", fundBody) + Assert.Contains("\"reservedCash\":\"52.50\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(1, orderCount listBody) + + [<Fact>] + member _.``subscription order rejects conflicting reuse of an idempotency key``() = + let code = seedInstrument () + let fundId = createFund 300.00m + let api = app () + let key = fixture.Key("order-conflict") + + let _, _ = postOrder api fundId key (orderBody code "30.00" "3.00") + + let conflictStatus, conflictBody = + postOrder api fundId key (orderBody code "31.00" "3.00") + + Assert.Equal(409, conflictStatus) + Assert.Contains("IDEMPOTENCY_CONFLICT", conflictBody) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"267.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"33.00\"", fundBody) + + [<Fact>] + member _.``subscription order validates amount fee and catalog input``() = + let code = seedInstrument () + let fundId = createFund 1000.00m + let api = app () + let key = fixture.Key("order-validation") + + let invalidBodies = + [ + orderBody code "0.00" "1.00" + orderBody code "-5.00" "1.00" + orderBody code "10.005" "1.00" + orderBody code "10.00" "0.00" + orderBody code "10.00" "1.005" + "{\"amount\":\"10.00\",\"feeAmount\":\"1.00\"}" + "{\"fundCode\":123,\"amount\":\"10.00\",\"feeAmount\":\"1.00\"}" + "{\"fundCode\":\"" + code + "\",\"amount\":\"abc\",\"feeAmount\":\"1.00\"}" + "[]" + "" + ] + + for invalidBody in invalidBodies do + let status, responseBody = postOrder api fundId key invalidBody + Assert.Equal(400, status) + Assert.Contains("INVALID_ORDER_REQUEST", responseBody) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"1000.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"0.00\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(0, orderCount listBody) + + let boundaryStatus, boundaryBody = + postOrder api fundId key (orderBody code "0.01" "0.01") + + Assert.Equal(201, boundaryStatus) + Assert.Contains("\"reservedTotal\":\"0.02\"", boundaryBody) + + let _, fundAfterBoundary = getFund api fundId + Assert.Contains("\"availableCash\":\"999.98\"", fundAfterBoundary) + Assert.Contains("\"reservedCash\":\"0.02\"", fundAfterBoundary) + + [<Fact>] + member _.``subscription order requires authentication and an idempotency key``() = + let fundId = createFund 100.00m + let api = app () + let body = orderBody "000001" "10.00" "1.00" + + let unauthorizedStatus, _ = + PersistenceTestHelpers.invoke + api + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ "Idempotency-Key", fixture.Key("order-unauthorized") ] + body + + Assert.Equal(401, unauthorizedStatus) + + let missingKeyStatus, missingKeyBody = + PersistenceTestHelpers.invoke + api + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ "Authorization", "Bearer test-token" ] + body + + Assert.Equal(400, missingKeyStatus) + Assert.Contains("INVALID_ORDER_REQUEST", missingKeyBody) + + [<Fact>] + member _.``subscription order rejects unknown funds and unknown fund codes without cash movement``() = + let code = seedInstrument () + let fundId = createFund 800.00m + let api = app () + + let invalidIdStatus, invalidIdBody = + PersistenceTestHelpers.invoke + api + "POST" + "/api/funds/not-a-guid/orders" + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key("order-bad-id") + ] + (orderBody code "10.00" "1.00") + + Assert.Equal(400, invalidIdStatus) + Assert.Contains("INVALID_FUND_ID", invalidIdBody) + + let unknownFundStatus, unknownFundBody = + postOrder api (Guid.NewGuid()) (fixture.Key("order-unknown-fund")) (orderBody code "10.00" "1.00") + + Assert.Equal(404, unknownFundStatus) + Assert.Contains("FUND_NOT_FOUND", unknownFundBody) + + let unknownCodeStatus, unknownCodeBody = + postOrder api fundId (fixture.Key("order-unknown-code")) (orderBody "999999" "10.00" "1.00") + + Assert.Equal(404, unknownCodeStatus) + Assert.Contains("INSTRUMENT_NOT_FOUND", unknownCodeBody) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"800.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"0.00\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(0, orderCount listBody) + + let missingListStatus, _ = getOrderList api (Guid.NewGuid()) + Assert.Equal(404, missingListStatus) + + [<Fact>] + member _.``subscription order refuses insufficient available cash atomically``() = + let code = seedInstrument () + let fundId = createFund 10.00m + let api = app () + + let status, body = postOrder api fundId (fixture.Key("order-insufficient")) (orderBody code "6.00" "5.00") + + Assert.Equal(409, status) + Assert.Contains("INSUFFICIENT_FUNDS", body) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"10.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"0.00\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(0, orderCount listBody) + + [<Fact>] + member _.``concurrent subscription orders reserve exactly the affordable amount``() = + let code = seedInstrument () + let fundId = createFund 10.00m + let api = app () + let body = orderBody code "3.50" "0.50" + let statuses = ConcurrentBag<int>() + + Parallel.For( + 0, + 8, + fun index -> + let key = fixture.Key(sprintf "order-concurrent-%s-%d" (Guid.NewGuid().ToString("N")) index) + let status, _ = postOrder api fundId key body + statuses.Add(status) + ) + |> ignore + + Assert.Equal(2, (statuses |> Seq.filter (fun status -> status = 201) |> Seq.length)) + Assert.Equal(6, (statuses |> Seq.filter (fun status -> status = 409) |> Seq.length)) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"2.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"8.00\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(2, orderCount listBody) + + [<Fact>] + member _.``real Kestrel API preserves subscription orders and idempotency across process restart``() = + let code = seedInstrument () + let key = fixture.Key("order-restart") + + use firstProcess = new ApiProcess(fixture.ConnectionString) + PersistenceTestHelpers.waitForApi firstProcess + + use client = new HttpClient() + client.BaseAddress <- Uri(firstProcess.BaseAddress) + + let fundBody = + "{\"name\":\"订单重启 FOF\",\"initialCash\":\"100.00\",\"initialUnitNav\":\"1.00000000\",\"isSynthetic\":true}" + + let fundStatus, fundResponse = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some(fixture.Key("order-restart-fund"))) + fundBody + + Assert.Equal(201, fundStatus) + let fundId = PersistenceTestHelpers.responseId fundResponse + + let createStatus, createResponse = + PersistenceTestHelpers.send + client + "POST" + (sprintf "/api/funds/%O/orders" fundId) + (Some "test-token") + (Some key) + (orderBody code "60.00" "4.00") + + Assert.Equal(201, createStatus) + Assert.Contains("\"reservedTotal\":\"64.00\"", createResponse) + Assert.Contains("\"status\":\"submitted\"", createResponse) + let orderId = PersistenceTestHelpers.responseId createResponse + + firstProcess.Stop() |> ignore + + use restartedProcess = new ApiProcess(fixture.ConnectionString) + PersistenceTestHelpers.waitForApi restartedProcess + + use restartedClient = new HttpClient() + restartedClient.BaseAddress <- Uri(restartedProcess.BaseAddress) + + let restartedFundStatus, restartedFundBody = + PersistenceTestHelpers.send + restartedClient + "GET" + (sprintf "/api/funds/%O" fundId) + (Some "test-token") + None + "" + + Assert.Equal(200, restartedFundStatus) + Assert.Contains("\"availableCash\":\"36.00\"", restartedFundBody) + Assert.Contains("\"reservedCash\":\"64.00\"", restartedFundBody) + + let restartedOrderListStatus, restartedOrderListBody = + PersistenceTestHelpers.send + restartedClient + "GET" + (sprintf "/api/funds/%O/orders" fundId) + (Some "test-token") + None + "" + + Assert.Equal(200, restartedOrderListStatus) + Assert.Equal(1, orderCount restartedOrderListBody) + Assert.Contains(sprintf "\"id\":\"%O\"" orderId, restartedOrderListBody) + + let replayStatus, replayResponse = + PersistenceTestHelpers.send + restartedClient + "POST" + (sprintf "/api/funds/%O/orders" fundId) + (Some "test-token") + (Some key) + (orderBody code "60.00" "4.00") + + Assert.Equal(200, replayStatus) + Assert.Equal(orderId, PersistenceTestHelpers.responseId replayResponse) |
