From a5ac465ab59197a55a222d30ebd2cf26ffe80f86 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 09:27:09 +0800 Subject: Fix F# option JSON serialization in confirmation/positions API responses Giraffe 6's default Newtonsoft serializer wrapped F# Some values as Value/IsSome/IsNone objects, breaking the confirmation and positions response contract (amounts must be exact decimal strings, absent fields null, quote object-or-null). Add OptionJsonConverter plus a shared serializer registration applied to both the Kestrel host and the in-process test fixture, and cover confirmation/positions over real HTTP including pending, conflict, cross-fund and Kestrel restart-replay regressions. --- tests/FundLab.Api.Tests/OrderTests.fs | 320 ++++++++++++++++++++++++++++ tests/FundLab.Api.Tests/PersistenceTests.fs | 5 +- 2 files changed, 324 insertions(+), 1 deletion(-) (limited to 'tests/FundLab.Api.Tests') diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs index 0c0d71a..d9725d5 100644 --- a/tests/FundLab.Api.Tests/OrderTests.fs +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -792,6 +792,43 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) = let confirmKey fundId orderId key = repository().ConfirmSubscriptionOrder(key, fundId, orderId) + let postOrderFor (code: string) (fundId: Guid) (idempotencyKey: string) (amount: string) (fee: string) = + let body = + sprintf + "{\"fundCode\":\"%s\",\"amount\":\"%s\",\"feeAmount\":\"%s\"}" + code + amount + fee + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + body + + let confirmOverHttp (fundId: Guid) (orderId: Guid) (idempotencyKey: string) (body: string) = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + body + + let getPositionsOverHttp (fundId: Guid) = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/positions" fundId) + [ "Authorization", "Bearer test-token" ] + "" + [] member _.``confirmation persists quote evidence cash and positions atomically``() = let fundId = createFund 10000.00m @@ -1227,3 +1264,286 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) = let positions = rebuilt.GetFundPositions(fundId) Assert.Equal(1, positions.Length) Assert.Equal(400.00000000m, positions.[0].Units) + + [] + member _.``real HTTP confirmation executes with server decided quote and exposes positions``() = + let code = seedInstrument () + let fundId = createFund 10000.00m + let navDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.Now + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuoteOnDate code 2.5m navDate collected None + + let createStatus, createResponse = postOrderFor code fundId (fixture.Key("http-confirm-order")) "1000.00" "0.00" + Assert.Equal(201, createStatus) + let orderId = PersistenceTestHelpers.responseId createResponse + + let poisonBody = + "{\"executionNav\":\"9.99\",\"confirmedAt\":\"2000-01-01T00:00:00Z\",\"tradeDate\":\"1990-01-01\",\"firstSeenAt\":\"2000-01-01T00:00:00Z\"}" + + let confirmStatus, confirmBody = confirmOverHttp fundId orderId (fixture.Key("http-confirm-key")) poisonBody + + Assert.Equal(200, confirmStatus) + Assert.Contains("\"status\":\"confirmed\"", confirmBody) + Assert.Contains("\"confirmedNav\":\"2.50000000\"", confirmBody) + Assert.Contains(sprintf "\"confirmedNavDate\":\"%s\"" (navDate.ToString("yyyy-MM-dd")), confirmBody) + Assert.Contains("\"confirmedUnits\":\"400.00000000\"", confirmBody) + Assert.Contains("\"confirmedInvestedCash\":\"1000.00\"", confirmBody) + Assert.Contains("\"confirmedResidualCash\":\"0.00\"", confirmBody) + Assert.Contains("\"pendingReason\":null", confirmBody) + Assert.Contains("\"quote\":{", confirmBody) + Assert.Contains("\"sourcePayloadHash\":\"confirm-quote-hash/", confirmBody) + Assert.DoesNotContain("9.99", confirmBody) + Assert.DoesNotContain("2000-01-01", confirmBody) + Assert.DoesNotContain("1990-01-01", confirmBody) + + let positionsStatus, positionsBody = getPositionsOverHttp fundId + Assert.Equal(200, positionsStatus) + Assert.Contains(sprintf "\"fundId\":\"%O\"" fundId, positionsBody) + Assert.Contains("\"availableCash\":\"9000.00\"", positionsBody) + Assert.Contains("\"reservedCash\":\"0.00\"", positionsBody) + Assert.Contains(sprintf "\"instrumentCode\":\"%s\"" code, positionsBody) + Assert.Contains("\"units\":\"400.00000000\"", positionsBody) + Assert.Contains("\"costCash\":\"1000.00\"", positionsBody) + + let replayStatus, replayBody = confirmOverHttp fundId orderId (fixture.Key("http-confirm-key")) poisonBody + Assert.Equal(200, replayStatus) + Assert.Equal(confirmBody, replayBody) + + let _, replayPositionsBody = getPositionsOverHttp fundId + use replayDocument = JsonDocument.Parse(replayPositionsBody) + Assert.Equal(1, replayDocument.RootElement.GetProperty("positions").GetArrayLength()) + + [] + member _.``real HTTP confirmation keeps pending without holdings or cash movement``() = + let code = seedInstrument () + let fundId = createFund 1000.00m + + let createStatus, createResponse = + postOrderFor code fundId (fixture.Key("http-pending-order")) "100.00" "0.00" + + Assert.Equal(201, createStatus) + let orderId = PersistenceTestHelpers.responseId createResponse + + let confirmStatus, confirmBody = confirmOverHttp fundId orderId (fixture.Key("http-pending-key")) "{}" + + Assert.Equal(200, confirmStatus) + Assert.Contains("\"status\":\"pending_nav\"", confirmBody) + Assert.Contains("\"pendingReason\":\"", confirmBody) + Assert.Contains("\"confirmedNav\":null", confirmBody) + Assert.Contains("\"quote\":null", confirmBody) + + let _, positionsBody = getPositionsOverHttp fundId + use document = JsonDocument.Parse(positionsBody) + Assert.Equal(0, document.RootElement.GetProperty("positions").GetArrayLength()) + Assert.Contains("\"availableCash\":\"900.00\"", positionsBody) + Assert.Contains("\"reservedCash\":\"100.00\"", positionsBody) + + let replayStatus, replayBody = confirmOverHttp fundId orderId (fixture.Key("http-pending-key")) "{}" + Assert.Equal(200, replayStatus) + Assert.Contains("\"status\":\"pending_nav\"", replayBody) + + [] + member _.``real HTTP confirmation enforces ownership auth and idempotency boundaries``() = + let code = seedInstrument () + let fundId = createFund 10000.00m + let otherFundId = createFund 10000.00m + let navDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.Now + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuoteOnDate code 2.5m navDate collected None + + let createStatus, createResponse = postOrderFor code fundId (fixture.Key("http-boundary-order")) "1000.00" "0.00" + Assert.Equal(201, createStatus) + let orderId = PersistenceTestHelpers.responseId createResponse + + let missingAuthStatus, _ = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId) + [ "Idempotency-Key", fixture.Key("http-no-auth") ] + "{}" + + Assert.Equal(401, missingAuthStatus) + + let missingKeyStatus, missingKeyBody = confirmOverHttp fundId orderId "" "{}" + Assert.Equal(400, missingKeyStatus) + Assert.Contains("INVALID_CONFIRM_REQUEST", missingKeyBody) + + let unknownOrderStatus, _ = confirmOverHttp fundId (Guid.NewGuid()) (fixture.Key("http-unknown-order")) "{}" + Assert.Equal(404, unknownOrderStatus) + + let invalidGuidStatus, invalidGuidBody = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/orders/not-a-guid/confirm" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key("http-bad-order-id") + ] + "{}" + + Assert.Equal(400, invalidGuidStatus) + Assert.Contains("INVALID_CONFIRM_REQUEST", invalidGuidBody) + + let crossFundStatus, crossFundBody = confirmOverHttp otherFundId orderId (fixture.Key("http-cross-fund")) "{}" + Assert.Equal(404, crossFundStatus) + Assert.Contains("ORDER_NOT_FOUND", crossFundBody) + + let confirmStatus, confirmBody = confirmOverHttp fundId orderId (fixture.Key("http-boundary-key")) "{}" + Assert.Equal(200, confirmStatus) + Assert.Contains("\"status\":\"confirmed\"", confirmBody) + + let doubleConfirmStatus, doubleConfirmBody = confirmOverHttp fundId orderId (fixture.Key("http-double-confirm")) "{}" + Assert.Equal(409, doubleConfirmStatus) + Assert.Contains("ORDER_ALREADY_CONFIRMED", doubleConfirmBody) + + [] + member _.``real HTTP concurrent confirms record exactly once without partial state``() = + let code = seedInstrument () + let fundId = createFund 10000.00m + let navDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.Now + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuoteOnDate code 2.5m navDate collected None + + let createStatus, createResponse = + postOrderFor code fundId (fixture.Key("http-concurrent-order")) "1000.00" "0.00" + + Assert.Equal(201, createStatus) + let orderId = PersistenceTestHelpers.responseId createResponse + let key = fixture.Key("http-concurrent-key") + let statuses = ConcurrentBag() + + Parallel.For( + 0, + 8, + fun _ -> + let status, body = confirmOverHttp fundId orderId key "{}" + Assert.Contains("\"status\":\"confirmed\"", body) + statuses.Add(status) + ) + |> ignore + + Assert.Equal(8, statuses.Count) + statuses |> Seq.iter (fun status -> Assert.Equal(200, status)) + + Assert.Equal( + 1L, + PersistenceTestHelpers.queryCount + fixture.ConnectionString + "SELECT count(*) FROM subscription_confirm_idempotencies WHERE order_id = @order_id" + [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]) + + Assert.Equal( + 1L, + PersistenceTestHelpers.queryCount + fixture.ConnectionString + "SELECT count(*) FROM subscription_order_events WHERE order_id = @order_id" + [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]) + + let _, positionsBody = getPositionsOverHttp fundId + use document = JsonDocument.Parse(positionsBody) + Assert.Equal(1, document.RootElement.GetProperty("positions").GetArrayLength()) + Assert.Contains("\"availableCash\":\"9000.00\"", positionsBody) + Assert.Contains("\"reservedCash\":\"0.00\"", positionsBody) + + [] + member _.``real Kestrel API confirms orders and preserves positions and replay across restart``() = + let code = seedInstrument () + let navDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.Now + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuoteOnDate code 2.5m navDate collected None + let key = fixture.Key("kestrel-confirm-key") + + use firstProcess = new ApiProcess(fixture.ConnectionString) + PersistenceTestHelpers.waitForApi firstProcess + + use client = new HttpClient() + client.BaseAddress <- Uri(firstProcess.BaseAddress) + + let fundStatus, fundResponse = + PersistenceTestHelpers.send + client + "POST" + "/api/funds" + (Some "test-token") + (Some(fixture.Key("kestrel-confirm-fund"))) + "{\"name\":\"确认重启 FOF\",\"initialCash\":\"10000.00\",\"initialUnitNav\":\"1.00000000\",\"isSynthetic\":true}" + + 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(fixture.Key("kestrel-confirm-order"))) + (sprintf "{\"fundCode\":\"%s\",\"amount\":\"1000.00\",\"feeAmount\":\"0.00\"}" code) + + Assert.Equal(201, createStatus) + let orderId = PersistenceTestHelpers.responseId createResponse + + let confirmStatus, confirmBody = + PersistenceTestHelpers.send + client + "POST" + (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId) + (Some "test-token") + (Some key) + "{}" + + Assert.Equal(200, confirmStatus) + Assert.Contains("\"status\":\"confirmed\"", confirmBody) + Assert.Contains("\"confirmedUnits\":\"400.00000000\"", confirmBody) + + firstProcess.Stop() |> ignore + + use restartedProcess = new ApiProcess(fixture.ConnectionString) + PersistenceTestHelpers.waitForApi restartedProcess + + use restartedClient = new HttpClient() + restartedClient.BaseAddress <- Uri(restartedProcess.BaseAddress) + + let positionsStatus, positionsBody = + PersistenceTestHelpers.send + restartedClient + "GET" + (sprintf "/api/funds/%O/positions" fundId) + (Some "test-token") + None + "" + + Assert.Equal(200, positionsStatus) + Assert.Contains("\"availableCash\":\"9000.00\"", positionsBody) + Assert.Contains("\"reservedCash\":\"0.00\"", positionsBody) + Assert.Contains(sprintf "\"instrumentCode\":\"%s\"" code, positionsBody) + + use positionsDocument = JsonDocument.Parse(positionsBody) + Assert.Equal(1, positionsDocument.RootElement.GetProperty("positions").GetArrayLength()) + + let replayStatus, replayBody = + PersistenceTestHelpers.send + restartedClient + "POST" + (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId) + (Some "test-token") + (Some key) + "{}" + + Assert.Equal(200, replayStatus) + Assert.Contains("\"status\":\"confirmed\"", replayBody) + Assert.Equal(confirmBody, replayBody) + + let cashStatus, cashBody = + PersistenceTestHelpers.send + restartedClient + "GET" + (sprintf "/api/funds/%O" fundId) + (Some "test-token") + None + "" + + Assert.Equal(200, cashStatus) + Assert.Contains("\"availableCash\":\"9000.00\"", cashBody) diff --git a/tests/FundLab.Api.Tests/PersistenceTests.fs b/tests/FundLab.Api.Tests/PersistenceTests.fs index 64e857c..5a049af 100644 --- a/tests/FundLab.Api.Tests/PersistenceTests.fs +++ b/tests/FundLab.Api.Tests/PersistenceTests.fs @@ -200,7 +200,10 @@ type ApiProcess(connectionString: string) = module PersistenceTestHelpers = let invoke (handler: HttpHandler) (method: string) (path: string) (headers: (string * string) list) (body: string) = let context = DefaultHttpContext() - use services = ServiceCollection().AddGiraffe().BuildServiceProvider() + use services = + (ServiceCollection() |> App.addOptionFriendlyJson) + .AddGiraffe() + .BuildServiceProvider() context.RequestServices <- services context.Request.Method <- method -- cgit v1.2.3