summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 06:23:10 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 06:23:10 +0800
commit03f89aea966dfe9edcd08695a79788a6be703b26 (patch)
tree3d09c85f4df050197df6c707492a17588c3335cf /tests
parentd870d67cd0dfe9d48205f16cb8fbcfc4bd5e0b05 (diff)
downloadfund-lab-03f89aea966dfe9edcd08695a79788a6be703b26.tar.gz
feat(api,web): 模拟认购订单创建/读取,幂等与预留现金(3d-1)
- API: POST/GET /api/funds/{fundId}/orders,advisory lock + 行级锁事务预留现金, 幂等键重放/冲突/校验/目录缺失错误码齐全,基金响应新增 reservedCash - Persistence: subscription_orders 与幂等表 + 保留现金列迁移 - Web: 认购表单与待确认订单列表(已提交·待确认),失败重试复用幂等键, token 轮换隔离延迟响应 - 测试: API 41(含并发不足/回滚/真实宿主重启回归)、Web 17、Domain 19; 浏览器 QA 58 项全过
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs400
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs1
3 files changed, 402 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index 63f0750..21778a3 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -23,6 +23,7 @@
<Compile Include="ApiTests.fs" />
<Compile Include="ProcessCollectorTests.fs" />
<Compile Include="PersistenceTests.fs" />
+ <Compile Include="OrderTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
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)
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index ed970bd..1d021a1 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -161,6 +161,7 @@ module FundBoundaryTests =
initialUnitNav = "1.00000000"
isSynthetic = false
availableCash = "12345.67"
+ reservedCash = "0.00"
status = "active"
}