From ac44797721121cb7e00b380ee5ed5d464b74da6f Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 08:13:13 +0800 Subject: Implement subscription order confirmation with locked trade dates and bound quote evidence --- tests/FundLab.Api.Tests/OrderTests.fs | 599 ++++++++++++++++++++++++++++++++++ 1 file changed, 599 insertions(+) (limited to 'tests/FundLab.Api.Tests') diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs index d1f57df..d975462 100644 --- a/tests/FundLab.Api.Tests/OrderTests.fs +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -473,3 +473,602 @@ type OrderTests(fixture: PostgresFixture) = Assert.Equal(0L, negativeCount) Assert.Equal(1L, zeroCount) + +type ConfirmationPolicyTests() = + let shanghai (hour: int) (minute: int) (second: int) (day: int) = + DateTimeOffset(2026, 9, day, hour, minute, second, TimeSpan.FromHours(8.0)) + + let navQuote navDate nav collectedAt publishedAt : ConfirmationPolicy.NavQuote = + { NavDate = navDate + Nav = nav + CollectedAt = collectedAt + PublishedAt = publishedAt } + + [] + member _.``trade date is the shanghai date before the 15:00 cutoff``() = + let submitted = shanghai 14 59 59 21 + Assert.Equal(DateOnly(2026, 9, 21), ConfirmationPolicy.tradeDateFor submitted) + + [] + member _.``trade date rolls to the next day at the 15:00 cutoff``() = + let submitted = shanghai 15 0 0 21 + Assert.Equal(DateOnly(2026, 9, 22), ConfirmationPolicy.tradeDateFor submitted) + + [] + member _.``trade date is derived from shanghai wall clock regardless of offset``() = + let utcBeforeCutoff = DateTimeOffset(2026, 9, 21, 6, 59, 59, TimeSpan.Zero) + let utcAtCutoff = DateTimeOffset(2026, 9, 21, 7, 0, 0, TimeSpan.Zero) + Assert.Equal(DateOnly(2026, 9, 21), ConfirmationPolicy.tradeDateFor utcBeforeCutoff) + Assert.Equal(DateOnly(2026, 9, 22), ConfirmationPolicy.tradeDateFor utcAtCutoff) + + [] + member _.``friday cutoff rolls the trade date to monday``() = + let submitted = shanghai 15 0 0 25 + Assert.Equal(DateOnly(2026, 9, 28), ConfirmationPolicy.tradeDateFor submitted) + + [] + member _.``weekend submissions roll the trade date to monday``() = + Assert.Equal(DateOnly(2026, 9, 28), ConfirmationPolicy.tradeDateFor (shanghai 10 0 0 26)) + Assert.Equal(DateOnly(2026, 9, 28), ConfirmationPolicy.tradeDateFor (shanghai 15 0 0 27)) + + [] + member _.``modeled trading day covers weekends only``() = + Assert.False(ConfirmationPolicy.isModeledTradingDay (DateOnly(2026, 9, 26))) + Assert.True(ConfirmationPolicy.isModeledTradingDay (DateOnly(2026, 9, 21))) + Assert.True(ConfirmationPolicy.isModeledTradingDay (DateOnly(2026, 10, 1))) + + [] + member _.``units truncate at eight decimals and invested cash at cents``() = + match ConfirmationPolicy.compute 1000.00m 2.375m with + | Ok confirmed -> + Assert.Equal(421.05263157m, confirmed.Units) + Assert.Equal(999.99m, confirmed.InvestedCash) + Assert.Equal(0.01m, confirmed.ResidualCash) + | Error reason -> failwithf "unexpected deferral: %s" reason + + [] + member _.``exact division keeps the full amount invested``() = + match ConfirmationPolicy.compute 100.00m 2.5m with + | Ok confirmed -> + Assert.Equal(40m, confirmed.Units) + Assert.Equal(100.00m, confirmed.InvestedCash) + Assert.Equal(0.00m, confirmed.ResidualCash) + | Error reason -> failwithf "unexpected deferral: %s" reason + + [] + member _.``amounts below one unit are deferred``() = + Assert.Equal( + Error "amount converts to zero units at this unit nav", + ConfirmationPolicy.compute 0.00000001m 2.5m + ) + + [] + member _.``unit amounts beyond database precision are deferred``() = + Assert.Equal( + Error "unit amount exceeds database precision", + ConfirmationPolicy.compute 999999999999999999.99m 0.00000001m + ) + + [] + member _.``non positive unit nav is rejected``() = + Assert.Equal(Error "unit nav must be positive", ConfirmationPolicy.compute 100.00m 0m) + + [] + member _.``usable nav quotes pass the no lookahead checks``() = + let quote = + navQuote (DateOnly(2026, 9, 21)) 2.5m (shanghai 14 0 0 21) (Some(shanghai 13 0 0 21)) + + let now = shanghai 15 0 0 21 + + Assert.Equal( + None, + ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now + ) + + [] + member _.``quotes observed before confirmation without a publish timestamp are usable``() = + let quote = navQuote (DateOnly(2026, 9, 21)) 2.5m (shanghai 14 0 0 21) None + let now = shanghai 15 0 0 21 + + Assert.Equal( + None, + ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now + ) + + [] + member _.``quotes collected after the confirmation moment defer without backdating``() = + let quote = navQuote (DateOnly(2026, 9, 21)) 2.5m (shanghai 16 0 0 21) None + let now = shanghai 15 0 0 21 + + Assert.Equal( + Some "nav for trade date 2026-09-21 is not yet collected", + ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now + ) + + [] + member _.``stale nav quotes defer confirmation``() = + let quote = navQuote (DateOnly(2026, 9, 20)) 2.5m (shanghai 14 0 0 21) None + let now = shanghai 15 0 0 21 + + Assert.Equal( + Some "nav for trade date 2026-09-21 is not available yet", + ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now + ) + + [] + member _.``future dated nav quotes defer confirmation``() = + let quote = navQuote (DateOnly(2026, 9, 22)) 2.5m (shanghai 14 0 0 21) None + let now = shanghai 15 0 0 21 + + Assert.Equal( + Some "nav for trade date 2026-09-21 is dated in the future", + ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now + ) + + [] + member _.``unpublished nav quotes defer confirmation``() = + let quote = + navQuote (DateOnly(2026, 9, 21)) 2.5m (shanghai 14 0 0 21) (Some(shanghai 16 0 0 21)) + + let now = shanghai 15 0 0 21 + + Assert.Equal( + Some "nav for trade date 2026-09-21 is not yet published", + ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 9, 21)) (DateOnly(2026, 9, 21)) now + ) + + [] + member _.``holiday trade dates without a matching nav stay pending``() = + let quote = navQuote (DateOnly(2026, 9, 30)) 2.5m (shanghai 10 0 0 30) None + let now = DateTimeOffset(2026, 10, 1, 15, 0, 0, TimeSpan.FromHours(8.0)) + + Assert.Equal( + Some "nav for trade date 2026-10-01 is not available yet", + ConfirmationPolicy.navDeferralReason quote (DateOnly(2026, 10, 1)) (DateOnly(2026, 10, 1)) now + ) + +[] +type SubscriptionConfirmationTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let seedInstrument () = + let code = Random.Shared.Next(0, 1000000).ToString("D6") + let payload = + { + Source = "akshare" + SourceRevision = "akshare-test/eastmoney" + CollectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero) + Instruments = [ { Code = code; Name = "确认测试基金"; FundType = None } ] + } + + repository().UpsertInstruments(payload, "confirm-test-hash") + code + + let createFund (initialCash: decimal) = + let command = + { + Name = "确认测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "confirm-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app () = App.createApplication (repository ()) + + let postOrder fundId idempotencyKey amount fee = + let body = + sprintf + "{\"fundCode\":\"%s\",\"amount\":\"%s\",\"feeAmount\":\"%s\"}" + (seedInstrument ()) + amount + fee + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + body + |> fun (status, response) -> + if status <> 201 then failwithf "unexpected order status %d: %s" status response + PersistenceTestHelpers.responseId response + + let truncateMicroseconds (moment: DateTimeOffset) = + let utc = moment.ToUniversalTime() + DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero) + + let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) (collectedAt: DateTimeOffset) (publishedAt: DateTimeOffset option) = + let payload: MarketDataNavPayload = + { + Source = "akshare" + SourceRevision = sprintf "akshare-test/%O" (Guid.NewGuid()) + CollectedAt = collectedAt + Code = code + Observations = + [ + { + NavDate = navDate + PublishedAt = publishedAt + Nav = nav + AccumulatedNav = Some nav + DailyReturn = Some 0.0m + } + ] + } + + repository().UpsertNavObservations(payload, "confirm-quote-hash") + + let insertQuote (code: string) (nav: decimal) (collectedAt: DateTimeOffset) (publishedAt: DateTimeOffset option) = + insertQuoteOnDate code nav (ConfirmationPolicy.tradeDateFor DateTimeOffset.Now) collectedAt publishedAt + + let execute (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- sql + + for name, value, dbType in parameters do + let parameter = command.Parameters.Add(name, dbType) + parameter.Value <- value + + command.ExecuteNonQuery() |> ignore + + let scalarDecimal (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- sql + + for name, value, dbType in parameters do + let parameter = command.Parameters.Add(name, dbType) + parameter.Value <- value + + command.ExecuteScalar() :?> decimal + + let scalarText (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- sql + + for name, value, dbType in parameters do + let parameter = command.Parameters.Add(name, dbType) + parameter.Value <- value + + command.ExecuteScalar() :?> string + + let orderFundCode orderId = + scalarText "SELECT fund_code FROM subscription_orders WHERE id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + let availableCash fundId = + scalarDecimal "SELECT available_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + let reservedCash fundId = + scalarDecimal "SELECT reserved_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + let orderStatus orderId = + scalarDecimal + "SELECT count(*)::numeric FROM subscription_orders WHERE id = @order_id AND status = 'confirmed'" + [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ] + |> int64 + + let pendingOrderReason orderId = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- "SELECT status || '|' || coalesce(pending_reason, '') FROM subscription_orders WHERE id = @order_id" + let parameter = command.Parameters.Add("order_id", NpgsqlTypes.NpgsqlDbType.Uuid) + parameter.Value <- box orderId + command.ExecuteScalar() :?> string + + let confirmKey fundId orderId key = repository().ConfirmSubscriptionOrder(key, fundId, orderId) + + [] + member _.``confirmation persists quote evidence cash and positions atomically``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-success-1" "1000.00" "0.00" + let code = orderFundCode orderId + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected None + let tradeDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.Now + let key = fixture.Key("confirm-success-1") + + match confirmKey fundId orderId key with + | OrderConfirmed confirmed -> + Assert.Equal("confirmed", confirmed.Status) + Assert.Equal(Some key, confirmed.ConfirmIdempotencyKey) + Assert.Equal(Some 400.00000000m, confirmed.ConfirmedUnits) + Assert.Equal(Some 1000.00m, confirmed.ConfirmedInvestedCash) + Assert.Equal(Some 0.00m, confirmed.ConfirmedResidualCash) + Assert.True(confirmed.ConfirmedAt.IsSome) + + match confirmed.ConfirmedQuote with + | Some quote -> + Assert.Equal(2.5m, quote.Nav) + Assert.Equal(tradeDate, quote.NavDate) + Assert.Equal("akshare", quote.Source) + Assert.Equal(collected, quote.CollectedAt) + Assert.Equal(None, quote.PublishedAt) + | None -> failwith "confirmed quote evidence missing" + + Assert.Equal(9000.00m, availableCash fundId) + Assert.Equal(0.00m, reservedCash fundId) + + let positions = repository().GetFundPositions(fundId) + Assert.Equal(1, positions.Length) + let position = positions.[0] + Assert.Equal(400.00000000m, position.Units) + Assert.Equal(1000.00m, position.CostCash) + Assert.Equal(Some 2.5m, position.ValuationNav) + Assert.Equal(Some tradeDate, position.ValuationNavDate) + | other -> failwithf "unexpected confirm result: %A" other + + [] + member _.``replaying the same idempotency key never double executes``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-replay-1" "1000.00" "0.00" + let code = orderFundCode orderId + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected None + let key = fixture.Key("confirm-replay-key") + + match confirmKey fundId orderId key with + | OrderConfirmed _ -> () + | other -> failwithf "unexpected first confirm result: %A" other + + let cashAfterFirst = availableCash fundId + + insertQuote code 9.9m (truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-8.0))) None + + match confirmKey fundId orderId key with + | ConfirmReplayed replayed -> + Assert.Equal("confirmed", replayed.Status) + + match replayed.ConfirmedQuote with + | Some quote -> + Assert.Equal(2.5m, quote.Nav) + Assert.Equal(collected, quote.CollectedAt) + Assert.Equal(None, quote.PublishedAt) + | None -> failwith "replayed quote evidence missing" + + Assert.Equal(400.00000000m, replayed.ConfirmedUnits |> Option.get) + | other -> failwithf "unexpected replay result: %A" other + + Assert.Equal(cashAfterFirst, availableCash fundId) + Assert.Equal(1, (repository().GetFundPositions(fundId)).Length) + 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 ]) + + [] + member _.``a confirmed order cannot be confirmed with a different key``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-other-key-1" "1000.00" "0.00" + let code = orderFundCode orderId + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected None + let cashAfterFirst = + match confirmKey fundId orderId (fixture.Key("confirm-other-key-first")) with + | OrderConfirmed _ -> availableCash fundId + | other -> failwithf "unexpected first confirm result: %A" other + + match confirmKey fundId orderId (fixture.Key("confirm-other-key-second")) with + | ConfirmAlreadyConfirmed -> Assert.Equal(cashAfterFirst, availableCash fundId) + | other -> failwithf "unexpected second confirm result: %A" other + + [] + member _.``a consumed confirm key cannot bind to another order``() = + let fundId = createFund 10000.00m + let firstOrder = postOrder fundId "confirm-consume-1" "1000.00" "0.00" + let code = orderFundCode firstOrder + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected None + let key = fixture.Key("confirm-consume-shared-key") + + match confirmKey fundId firstOrder key with + | OrderConfirmed _ -> () + | other -> failwithf "unexpected first confirm result: %A" other + + let secondOrder = postOrder fundId "confirm-consume-2" "500.00" "0.00" + + match confirmKey fundId secondOrder key with + | ConfirmIdempotencyConflict -> + Assert.Equal(0L, orderStatus secondOrder) + Assert.Equal(400.00000000m, (repository().GetFundPositions(fundId)).[0].Units) + | other -> failwithf "unexpected conflict result: %A" other + + [] + member _.``mid confirmation failure rolls back every write and the same key can retry``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-rollback-1" "1000.00" "0.00" + let code = orderFundCode orderId + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected None + let key = fixture.Key("confirm-rollback-key") + + execute + "UPDATE subscription_orders SET reserved_total = reserved_total + 1 WHERE id = @order_id" + [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + Assert.Throws(fun () -> confirmKey fundId orderId key |> ignore) |> ignore + + Assert.Equal(0L, orderStatus orderId) + Assert.Equal(0L, PersistenceTestHelpers.queryCount fixture.ConnectionString "SELECT count(*) FROM subscription_confirm_idempotencies WHERE order_id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]) + Assert.Equal(0L, PersistenceTestHelpers.queryCount fixture.ConnectionString "SELECT count(*) FROM subscription_order_events WHERE order_id = @order_id" [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ]) + Assert.Equal(0L, PersistenceTestHelpers.queryCount fixture.ConnectionString "SELECT count(*) FROM fund_positions WHERE fund_id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ]) + Assert.Equal(9000.00m, availableCash fundId) + Assert.Equal(1000.00m, reservedCash fundId) + + execute + "UPDATE subscription_orders SET reserved_total = reserved_total - 1 WHERE id = @order_id" + [ "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + match confirmKey fundId orderId key with + | OrderConfirmed confirmed -> + Assert.Equal(400.00000000m, confirmed.ConfirmedUnits |> Option.get) + Assert.Equal(9000.00m, availableCash fundId) + | other -> failwithf "unexpected retry result: %A" other + + [] + member _.``missing nav stays pending without consuming the key and confirms later``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-pending-1" "1000.00" "0.00" + let key = fixture.Key("confirm-pending-key") + + match confirmKey fundId orderId key with + | ConfirmPendingNav pending -> + Assert.Equal("pending_nav", pending.Status) + Assert.True(pending.PendingReason.IsSome) + Assert.True(pending.PendingReason.Value.Contains("not available yet")) + Assert.Equal(None, pending.ConfirmIdempotencyKey) + Assert.Equal(None, pending.ConfirmedAt) + Assert.Equal(None, pending.ConfirmedQuote) + | other -> failwithf "unexpected pending result: %A" other + + let code = orderFundCode orderId + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected None + + match confirmKey fundId orderId key with + | OrderConfirmed confirmed -> + Assert.Equal(Some key, confirmed.ConfirmIdempotencyKey) + Assert.Equal(9000.00m, availableCash fundId) + + match confirmed.ConfirmedQuote with + | Some quote -> + Assert.Equal(ConfirmationPolicy.tradeDateFor DateTimeOffset.Now, quote.NavDate) + Assert.Equal(collected, quote.CollectedAt) + | None -> failwith "late confirmed quote evidence missing" + | other -> failwithf "unexpected late confirm result: %A" other + + [] + member _.``retrying after the cutoff keeps the original order trade date``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-overnight-1" "1000.00" "0.00" + let code = orderFundCode orderId + let originalTradeDate = ConfirmationPolicy.tradeDateFor (DateTimeOffset.Now.AddDays(-1.0)) + let originalCollected = truncateMicroseconds (DateTimeOffset.Now.AddDays(-1.0).AddHours(-2.0)) + insertQuoteOnDate code 3.7m originalTradeDate originalCollected None + + execute + "UPDATE subscription_orders SET submitted_at = submitted_at - interval '1 day', trade_date = @trade_date WHERE id = @order_id" + [ "trade_date", box originalTradeDate, NpgsqlTypes.NpgsqlDbType.Date; "order_id", box orderId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + match confirmKey fundId orderId (fixture.Key("confirm-overnight-key")) with + | OrderConfirmed confirmed -> + Assert.Equal(originalTradeDate, confirmed.TradeDate) + + match confirmed.ConfirmedQuote with + | Some quote -> + Assert.Equal(originalTradeDate, quote.NavDate) + Assert.Equal(3.7m, quote.Nav) + Assert.Equal(originalCollected, quote.CollectedAt) + | None -> failwith "overnight quote evidence missing" + + Assert.Equal(Some 270.27027027m, confirmed.ConfirmedUnits) + Assert.Equal(Some 999.99m, confirmed.ConfirmedInvestedCash) + Assert.Equal(9000.01m, availableCash fundId) + | other -> failwithf "unexpected overnight confirm result: %A" other + + [] + member _.``quotes collected after the confirmation moment stay pending``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-future-collect-1" "1000.00" "0.00" + let code = orderFundCode orderId + let futureCollected = truncateMicroseconds (DateTimeOffset.Now.AddHours(1.0)) + insertQuote code 2.5m futureCollected None + + match confirmKey fundId orderId (fixture.Key("confirm-future-collect-key")) with + | ConfirmPendingNav pending -> + Assert.Equal("pending_nav", pending.Status) + Assert.True(pending.PendingReason.Value.Contains("not yet collected")) + Assert.Equal(None, pending.ConfirmIdempotencyKey) + | other -> failwithf "unexpected future collected result: %A" other + + [] + member _.``concurrent confirms with distinct keys execute exactly once``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-parallel-1" "1000.00" "0.00" + let code = orderFundCode orderId + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected None + + let results = + [| 1..4 |] + |> Array.map (fun index -> + Task.Run(fun () -> + confirmKey fundId orderId (fixture.Key(sprintf "confirm-parallel-key-%d" index)))) + |> Task.WhenAll + |> fun task -> task.GetAwaiter().GetResult() + |> Array.toList + + let confirmedCount = + results + |> List.filter (function + | OrderConfirmed _ -> true + | _ -> false) + |> List.length + + Assert.Equal(1, confirmedCount) + + for result in results do + match result with + | OrderConfirmed _ -> () + | ConfirmAlreadyConfirmed + | ConfirmIdempotencyConflict -> () + | other -> failwithf "unexpected concurrent result: %A" other + + Assert.Equal(9000.00m, availableCash fundId) + Assert.Equal(0.00m, reservedCash fundId) + Assert.Equal(1, (repository().GetFundPositions(fundId)).Length) + Assert.Equal(400.00000000m, (repository().GetFundPositions(fundId)).[0].Units) + + [] + member _.``a rebuilt repository re reads the persisted evidence``() = + let fundId = createFund 10000.00m + let orderId = postOrder fundId "confirm-rebuild-1" "1000.00" "0.00" + let code = orderFundCode orderId + let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + insertQuote code 2.5m collected (Some(truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-5.0)))) + let key = fixture.Key("confirm-rebuild-key") + + match confirmKey fundId orderId key with + | OrderConfirmed _ -> () + | other -> failwithf "unexpected confirm result: %A" other + + let rebuilt = FundRepository(fixture.ConnectionString) + rebuilt.EnsureSchema() + + let orders = rebuilt.GetSubscriptionOrders(fundId) + Assert.Equal(1, orders.Length) + let order = orders.[0] + + match order.ConfirmedQuote with + | Some quote -> + Assert.Equal(2.5m, quote.Nav) + Assert.Equal("akshare", quote.Source) + Assert.Equal(collected, quote.CollectedAt) + Assert.True(quote.PublishedAt.IsSome) + | None -> failwith "rebuilt quote evidence missing" + + Assert.Equal(Some key, order.ConfirmIdempotencyKey) + Assert.Equal(Some 400.00000000m, order.ConfirmedUnits) + Assert.Equal(Some 1000.00m, order.ConfirmedInvestedCash) + Assert.Equal(Some 0.00m, order.ConfirmedResidualCash) + + let positions = rebuilt.GetFundPositions(fundId) + Assert.Equal(1, positions.Length) + Assert.Equal(400.00000000m, positions.[0].Units) -- cgit v1.2.3