summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/FundLab.Api/App.fs192
-rw-r--r--src/FundLab.Api/Program.fs1
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs320
-rw-r--r--tests/FundLab.Api.Tests/PersistenceTests.fs5
4 files changed, 517 insertions, 1 deletions
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<option<_>>
+
+ 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<Giraffe.Json.ISerializer>(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 =
[<EntryPoint>]
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" ]
+ ""
+
[<Fact>]
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)
+
+ [<Fact>]
+ 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())
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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<int>()
+
+ 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)
+
+ [<Fact>]
+ 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