summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs320
-rw-r--r--tests/FundLab.Api.Tests/PersistenceTests.fs5
2 files changed, 324 insertions, 1 deletions
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