summaryrefslogtreecommitdiff
path: root/tests/FundLab.Web.Tests/BoundaryTests.fs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/FundLab.Web.Tests/BoundaryTests.fs')
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs232
1 files changed, 232 insertions, 0 deletions
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index 1d021a1..d48d08b 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -327,3 +327,235 @@ module FundBoundaryTests =
let stale, _ = update (FundReadCompleted(2, { fund with availableCash = "1.00" })) updated
Assert.Equal("12000.00", stale.createdFund.Value.availableCash)
+
+module ConfirmationBoundaryTests =
+
+ let sampleFundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+
+ let sampleOrderId = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
+
+ let someText (value: string) : obj = box { case = "Some"; fields = [| value |] }
+
+ let sampleRawOrder () : RawOrder =
+ {
+ id = sampleOrderId
+ fundCode = "000001"
+ amount = "100.00"
+ feeAmount = "1.50"
+ reservedTotal = "101.50"
+ status = "submitted"
+ submittedAt = "2026-09-21T10:00:00+00:00"
+ tradeDate = "2026-09-21"
+ pendingReason = null
+ confirmedAt = null
+ confirmedNav = null
+ confirmedNavDate = null
+ confirmedUnits = null
+ confirmedInvestedCash = null
+ confirmedResidualCash = null
+ }
+
+ let sampleConfirmedRaw () : RawOrder =
+ { sampleRawOrder () with
+ status = "confirmed"
+ pendingReason = null
+ confirmedAt = someText "2026-09-21T11:00:00+00:00"
+ confirmedNav = someText "2.50000000"
+ confirmedNavDate = someText "2026-09-21"
+ confirmedUnits = someText "40.00000000"
+ confirmedInvestedCash = someText "100.00"
+ confirmedResidualCash = someText "0.00" }
+
+ let samplePendingRaw () : RawOrder =
+ { sampleRawOrder () with
+ status = "pending_nav"
+ pendingReason = someText "nav for trade date 2026-09-21 is not available yet" }
+
+ let samplePositions () : RawPositions =
+ {
+ availableCash = "9900.00"
+ reservedCash = "101.50"
+ positions =
+ [|
+ { instrumentCode = "000001"
+ units = "40.00000000"
+ costCash = "100.00"
+ lastConfirmedAt = "2026-09-21T11:00:00+00:00"
+ valuationNav = someText "2.61000000"
+ valuationNavDate = someText "2026-09-21"
+ valuationCollectedAt = someText "2026-09-21T09:30:00+00:00" }
+ { instrumentCode = "110011"
+ units = "10.00000000"
+ costCash = "25.00"
+ lastConfirmedAt = "2026-09-21T11:00:00+00:00"
+ valuationNav = null
+ valuationNavDate = null
+ valuationCollectedAt = null }
+ |]
+ }
+
+ let sampleFund () : FundSummary =
+ {
+ id = sampleFundId
+ name = "稳健一号"
+ currency = "CNY"
+ initialCash = "10000.00"
+ initialUnitNav = "1.00000000"
+ isSynthetic = false
+ availableCash = "9900.00"
+ reservedCash = "101.50"
+ status = "active"
+ }
+
+ let withOrder model order = { model with token = "qa-token"; createdFund = Some(sampleFund ()); orders = [ order ] }
+
+ [<Fact>]
+ let ``optional text decodes plain strings and the legacy case fields envelope`` () =
+ Assert.Equal(Some "1.19020000", Api.decodeOptionalText (box "1.19020000"))
+ Assert.Equal(None, Api.decodeOptionalText null)
+ Assert.Equal(Some "混合型", Api.decodeOptionalText (someText "混合型"))
+ Assert.Equal(None, Api.decodeOptionalText (box { case = "None"; fields = [||] }))
+
+ [<Fact>]
+ let ``order decode keeps decimal strings and decodes option fields`` () =
+ let confirmed = Api.decodeOrder (sampleConfirmedRaw ())
+ let pending = Api.decodeOrder (samplePendingRaw ())
+
+ Assert.Equal("confirmed", confirmed.status)
+ Assert.Equal("2.50000000", confirmed.confirmedNav.Value)
+ Assert.Equal("40.00000000", confirmed.confirmedUnits.Value)
+ Assert.Equal("100.00", confirmed.confirmedInvestedCash.Value)
+ Assert.Equal("0.00", confirmed.confirmedResidualCash.Value)
+ Assert.Equal(None, confirmed.pendingReason)
+ Assert.Equal("pending_nav", pending.status)
+ Assert.Equal(Some "nav for trade date 2026-09-21 is not available yet", pending.pendingReason)
+ Assert.Equal(None, pending.confirmedNav)
+
+ [<Fact>]
+ let ``positions decode keeps exact strings and flags missing valuation`` () =
+ let decoded = Api.decodePositions (samplePositions ())
+
+ Assert.Equal("9900.00", decoded.availableCash)
+ Assert.Equal("101.50", decoded.reservedCash)
+ Assert.Equal(2, decoded.positions.Length)
+
+ let first = decoded.positions[0]
+
+ Assert.Equal("40.00000000", first.units)
+ Assert.Equal("100.00", first.costCash)
+ Assert.Equal(Some "2.61000000", first.valuationNav)
+ Assert.Equal(Some "2026-09-21", first.valuationNavDate)
+
+ let second = decoded.positions[1]
+
+ Assert.Equal("10.00000000", second.units)
+ Assert.Equal(None, second.valuationNav)
+ Assert.Equal(None, second.valuationNavDate)
+
+ [<Fact>]
+ let ``confirm request requires fund token and unique in-flight guard`` () =
+ let noFund, _ = update (OrderConfirmRequested sampleOrderId) { init () with token = "qa-token" }
+
+ Assert.Equal(0, noFund.confirmSeq)
+ Assert.False(noFund.confirmInFlight)
+
+ let noToken, _ =
+ update (OrderConfirmRequested sampleOrderId) { init () with createdFund = Some(sampleFund ()) }
+
+ Assert.Equal(Some "请输入 API token", noToken.error)
+ Assert.Equal(0, noToken.confirmSeq)
+
+ let started, _ =
+ update (OrderConfirmRequested sampleOrderId) (withOrder { init () with confirmInFlight = false } (Api.decodeOrder (sampleRawOrder ())))
+
+ Assert.Equal(1, started.confirmSeq)
+ Assert.True(started.confirmInFlight)
+ Assert.Equal(sampleOrderId, started.lastConfirmAttempt.Value.orderId)
+ Assert.Equal(32, started.lastConfirmAttempt.Value.idempotencyKey.Length)
+
+ [<Fact>]
+ let ``confirm completion replaces the order row and keeps exact decimals`` () =
+ let model =
+ withOrder
+ { init () with confirmSeq = 2; confirmInFlight = true; lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } }
+ (Api.decodeOrder (sampleRawOrder ()))
+
+ let updated, _ = update (OrderConfirmCompleted(2, sampleOrderId, sampleConfirmedRaw ())) model
+
+ Assert.False(updated.confirmInFlight)
+ Assert.Equal(None, updated.lastConfirmAttempt)
+ Assert.Equal(1, updated.orders.Length)
+
+ let confirmed = updated.orders[0]
+
+ Assert.Equal("confirmed", confirmed.status)
+ Assert.Equal("2.50000000", confirmed.confirmedNav.Value)
+ Assert.Equal("40.00000000", confirmed.confirmedUnits.Value)
+
+ [<Fact>]
+ let ``stale confirm completion is ignored`` () =
+ let model =
+ withOrder
+ { init () with confirmSeq = 5; confirmInFlight = true; lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } }
+ (Api.decodeOrder (sampleRawOrder ()))
+
+ let stale, _ = update (OrderConfirmCompleted(4, sampleOrderId, sampleConfirmedRaw ())) model
+
+ Assert.True(stale.confirmInFlight)
+ Assert.Equal("submitted", stale.orders[0].status)
+
+ [<Fact>]
+ let ``confirm failure clears flight and surfaces error`` () =
+ let model =
+ withOrder
+ { init () with confirmSeq = 1; confirmInFlight = true; lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } }
+ (Api.decodeOrder (sampleRawOrder ()))
+
+ let failed, _ = update (OrderConfirmFailed(1, sampleOrderId, "confirm failed")) model
+
+ Assert.False(failed.confirmInFlight)
+ Assert.Equal(None, failed.lastConfirmAttempt)
+ Assert.Equal(Some "confirm failed", failed.error)
+
+ [<Fact>]
+ let ``positions completion stores payload and stale reads are ignored`` () =
+ let model = { init () with token = "qa-token"; createdFund = Some(sampleFund ()); positionsSeq = 1; positionsInFlight = true }
+
+ let updated, _ = update (PositionsReadCompleted(1, samplePositions ())) model
+
+ Assert.False(updated.positionsInFlight)
+ Assert.Equal(Some "9900.00", updated.positions |> Option.map (fun p -> p.availableCash))
+ Assert.Equal(2, updated.positions.Value.positions.Length)
+
+ let stale, _ = update (PositionsReadCompleted(0, samplePositions ())) updated
+
+ Assert.Equal(2, stale.positions.Value.positions.Length)
+
+ [<Fact>]
+ let ``token change clears confirm and positions state and invalidates stale responses`` () =
+ let model =
+ { withOrder (init ()) (Api.decodeOrder (sampleRawOrder ())) with
+ token = "old-token"
+ confirmSeq = 3
+ confirmInFlight = true
+ lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId }
+ positions = Some(Api.decodePositions (samplePositions ()))
+ positionsSeq = 3
+ positionsInFlight = true }
+
+ let cleared, _ = update (TokenChanged "new-token") model
+
+ Assert.Empty(cleared.orders)
+ Assert.Equal(None, cleared.createdFund)
+ Assert.Equal(None, cleared.positions)
+ Assert.False(cleared.confirmInFlight)
+ Assert.False(cleared.positionsInFlight)
+ Assert.Equal(None, cleared.lastConfirmAttempt)
+
+ let staleConfirm, _ = update (OrderConfirmCompleted(3, sampleOrderId, sampleConfirmedRaw ())) cleared
+
+ Assert.Empty(staleConfirm.orders)
+
+ let stalePositions, _ = update (PositionsReadCompleted(3, samplePositions ())) cleared
+
+ Assert.Equal(None, stalePositions.positions)