diff options
Diffstat (limited to 'tests/FundLab.Web.Tests/BoundaryTests.fs')
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 232 |
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) |
