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. --- src/FundLab.Api/App.fs | 192 +++++++++++++++++ src/FundLab.Api/Program.fs | 1 + tests/FundLab.Api.Tests/OrderTests.fs | 320 ++++++++++++++++++++++++++++ tests/FundLab.Api.Tests/PersistenceTests.fs | 5 +- 4 files changed, 517 insertions(+), 1 deletion(-) diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index a2aa271..839a641 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -6,6 +6,37 @@ open System.IO open System.Text.Json open Giraffe open Microsoft.AspNetCore.Http +open Microsoft.Extensions.DependencyInjection +open Microsoft.FSharp.Reflection + +type OptionJsonConverter() = + inherit Newtonsoft.Json.JsonConverter() + + override _.CanConvert(objectType: Type) = + objectType.IsGenericType + && objectType.GetGenericTypeDefinition() = typedefof> + + override _.WriteJson(writer: Newtonsoft.Json.JsonWriter, value: obj, serializer: Newtonsoft.Json.JsonSerializer) = + if isNull value then + writer.WriteNull() + else + let _, fields = FSharpValue.GetUnionFields(value, value.GetType()) + + match fields with + | [| inner |] -> serializer.Serialize(writer, inner) + | _ -> writer.WriteNull() + + override _.ReadJson(reader: Newtonsoft.Json.JsonReader, objectType: Type, existingValue: obj, serializer: Newtonsoft.Json.JsonSerializer) = + let cases = FSharpType.GetUnionCases objectType + let innerType = objectType.GetGenericArguments().[0] + + if reader.TokenType = Newtonsoft.Json.JsonToken.Null then + FSharpValue.MakeUnion(cases.[0], [||]) + else + let inner = serializer.Deserialize(reader, innerType) + let someCase = cases |> Array.find (fun case -> case.Name = "Some") + FSharpValue.MakeUnion(someCase, [| inner |]) + type EmptyPortfolioResponse = { @@ -39,6 +70,60 @@ type SubscriptionOrderApiResponse = isSynthetic: bool } +type ConfirmedQuoteEvidenceResponse = + { + navDate: string + nav: string + source: string + sourceRevision: string + sourceCollectedAt: string + publishedAt: string option + sourcePayloadHash: string + firstSeenAt: string + } + +type SubscriptionOrderDetailResponse = + { + id: Guid + fundId: Guid + fundCode: string + amount: string + feeAmount: string + reservedTotal: string + status: string + submittedAt: string + tradeDate: string + confirmIdempotencyKey: string option + pendingReason: string option + confirmedAt: string option + confirmedNav: string option + confirmedNavDate: string option + confirmedUnits: string option + confirmedInvestedCash: string option + confirmedResidualCash: string option + quote: ConfirmedQuoteEvidenceResponse option + isSynthetic: bool + } + +type FundPositionResponse = + { + instrumentCode: string + units: string + costCash: string + lastConfirmedAt: string + valuationNav: string option + valuationNavDate: string option + valuationCollectedAt: string option + } + +type FundPositionsResponse = + { + fundId: Guid + availableCash: string + reservedCash: string + positions: FundPositionResponse list + } + type ApiErrorResponse = { error: string @@ -83,6 +168,15 @@ type MarketDataNavApiResponse = } module App = + let addOptionFriendlyJson (services: IServiceCollection) = + let settings = + Newtonsoft.Json.JsonSerializerSettings( + ContractResolver = Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() + ) + + settings.Converters.Add(OptionJsonConverter()) + services.AddSingleton(NewtonsoftJson.Serializer settings) + let private invariant = CultureInfo.InvariantCulture let private cashText (value: decimal) = value.ToString("0.00", invariant) @@ -117,6 +211,43 @@ module App = isSynthetic = order.IsSynthetic } + let private confirmationResponse (order: SubscriptionOrderRecord) : SubscriptionOrderDetailResponse = + let quoteEvidence = + order.ConfirmedQuote + |> Option.map (fun quote -> + { + navDate = dateText quote.NavDate + nav = decimalText quote.Nav + source = quote.Source + sourceRevision = quote.Revision + sourceCollectedAt = timestampText quote.CollectedAt + publishedAt = quote.PublishedAt |> Option.map timestampText + sourcePayloadHash = quote.PayloadHash + firstSeenAt = timestampText quote.FirstSeenAt + }) + + { + id = order.Id + fundId = order.FundId + fundCode = order.FundCode + amount = cashText order.Amount + feeAmount = cashText order.FeeAmount + reservedTotal = cashText order.ReservedTotal + status = order.Status + submittedAt = timestampText order.SubmittedAt + tradeDate = dateText order.TradeDate + confirmIdempotencyKey = order.ConfirmIdempotencyKey + pendingReason = order.PendingReason + confirmedAt = order.ConfirmedAt |> Option.map timestampText + confirmedNav = order.ConfirmedQuote |> Option.map (fun quote -> decimalText quote.Nav) + confirmedNavDate = order.ConfirmedQuote |> Option.map (fun quote -> dateText quote.NavDate) + confirmedUnits = order.ConfirmedUnits |> Option.map decimalText + confirmedInvestedCash = order.ConfirmedInvestedCash |> Option.map cashText + confirmedResidualCash = order.ConfirmedResidualCash |> Option.map cashText + quote = quoteEvidence + isSynthetic = order.IsSynthetic + } + let private errorResponse status error message : HttpHandler = setStatusCode status >=> json ({ @@ -317,6 +448,65 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "order persistence failed" next ctx + let private confirmOrder (repository: FundRepository) (fundIdText: string) (orderIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText, Guid.TryParse orderIdText with + | (false, _), _ + | _, (false, _) -> + errorResponse 400 "INVALID_CONFIRM_REQUEST" "fund id and order id must be UUIDs" next ctx + | (true, fundId), (true, orderId) -> + let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString() + + try + match repository.ConfirmSubscriptionOrder(idempotencyKey, fundId, orderId) with + | OrderConfirmed order + | ConfirmReplayed order + | ConfirmPendingNav order -> json (confirmationResponse order) next ctx + | ConfirmIdempotencyConflict -> + errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request" next ctx + | ConfirmAlreadyConfirmed -> + errorResponse 409 "ORDER_ALREADY_CONFIRMED" "order was already confirmed with a different idempotency key" next ctx + | ConfirmOrderNotFound -> errorResponse 404 "ORDER_NOT_FOUND" "order was not found" next ctx + | ConfirmInvalidStatus -> + errorResponse 409 "ORDER_INVALID_STATUS" "order is not in a confirmable status" next ctx + | ConfirmInvalid message -> errorResponse 400 "INVALID_CONFIRM_REQUEST" message next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "order confirmation failed" next ctx + + let private getPositions (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx + | true, fundId -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some fund -> + let positions = + repository.GetFundPositions fundId + |> List.map (fun position -> + { + instrumentCode = position.InstrumentCode + units = decimalText position.Units + costCash = cashText position.CostCash + lastConfirmedAt = timestampText position.LastConfirmedAt + valuationNav = position.ValuationNav |> Option.map decimalText + valuationNavDate = position.ValuationNavDate |> Option.map dateText + valuationCollectedAt = position.ValuationCollectedAt |> Option.map timestampText + }) + + json + ({ + fundId = fund.Id + availableCash = cashText fund.AvailableCash + reservedCash = cashText fund.ReservedCash + positions = positions + } : FundPositionsResponse) + next + ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "position persistence failed" next ctx + let private marketDataError (failure: MarketDataFailure) : HttpHandler = let status, error, message = match failure with @@ -414,6 +604,8 @@ module App = POST >=> route "/funds" >=> createFund repository POST >=> routef "/funds/%s/orders" (createOrder repository) GET >=> routef "/funds/%s/orders" (getOrders repository) + POST >=> routef "/funds/%s/orders/%s/confirm" (fun (fundId, orderId) -> confirmOrder repository fundId orderId) + GET >=> routef "/funds/%s/positions" (getPositions repository) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs index d4f2c76..b7d0ef6 100644 --- a/src/FundLab.Api/Program.fs +++ b/src/FundLab.Api/Program.fs @@ -13,6 +13,7 @@ let private requiredEnvironment name = [] let main argv = let builder = WebApplication.CreateBuilder(argv) + builder.Services |> App.addOptionFriendlyJson |> ignore builder.Services.AddGiraffe() |> ignore let repository = FundRepository(requiredEnvironment "FUND_LAB_DATABASE_URL") repository.EnsureSchema() 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