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 [] 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() [] 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) [] 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) [] 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) [] 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) [] 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) [] 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) [] 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) [] 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() 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) [] 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)