From 0183b86f18d9897ff9487e6d864898954397991d Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 04:45:10 +0800 Subject: Add bond buy to holdings minimal vertical slice (3d-22) --- tests/FundLab.Api.Tests/BondTradeTests.fs | 170 +++++++++++++++++++++++ tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Web.Tests/BoundaryTests.fs | 52 +++++++ 3 files changed, 223 insertions(+) create mode 100644 tests/FundLab.Api.Tests/BondTradeTests.fs (limited to 'tests') diff --git a/tests/FundLab.Api.Tests/BondTradeTests.fs b/tests/FundLab.Api.Tests/BondTradeTests.fs new file mode 100644 index 0000000..76827f5 --- /dev/null +++ b/tests/FundLab.Api.Tests/BondTradeTests.fs @@ -0,0 +1,170 @@ +namespace FundLab.Api.Tests + +open System +open System.Threading +open Xunit +open FundLab.Api + +module BondTradeSupport = + let stubQuoteProbe (quote: BondQuote) = + { new IBondQuoteProbe with + member _.GetQuote(_, _) = Ok quote } + + let failingQuoteProbe (failure: MarketDataFailure) = + { new IBondQuoteProbe with + member _.GetQuote(_, _) = Error failure } + + let quote110075 = + { + Code = "110075" + SourceRevision = "akshare-test/hs-cov-spot" + Name = Some "南航转债" + Price = Some 106.368m + CleanPrice = Some 106.368m + AccruedInterest = None + Date = None + MaturityDate = Some(DateOnly(2026, 10, 15)) + } + + let appWithQuote (probe: IBondQuoteProbe) = + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = probe + StockQuotes = NavDateProbeSupport.stubStockQuoteProbe + StockDaily = NavDateProbeSupport.stubStockDailyProbe } + +[] +type BondTradeTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let createFund () = + let command = + { + Name = "债券闭环测试 FOF" + InitialCash = 100000.00m + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "bond-trade-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 probe = + let probes = BondTradeSupport.appWithQuote probe + App.createApplicationWithProbes (repository ()) probes + + let buy (probe: IBondQuoteProbe) fundId body idempotencyKey = + PersistenceTestHelpers.invoke + (app probe) + "POST" + (sprintf "/api/funds/%O/bond-trades" fundId) + [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] + body + + let readPositions (probe: IBondQuoteProbe) fundId = + PersistenceTestHelpers.invoke + (app probe) + "GET" + (sprintf "/api/funds/%O/bond-positions" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + [] + member _.``buying a bond with a live quote records a trade and a position``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let status, body = + buy probe fundId """{"instrumentCode":"110075","quantity":"10"}""" (fixture.Key "bond-trade-buy-ok") + + Assert.Equal(201, status) + Assert.Contains("\"instrumentCode\":\"110075\"", body) + Assert.Contains("南航转债", body) + Assert.Contains("106.368", body) + Assert.Contains("1063.68", body) + + let status, positionsBody = readPositions probe fundId + Assert.Equal(200, status) + Assert.Contains("\"instrumentCode\":\"110075\"", positionsBody) + Assert.Contains("\"quantity\":\"10.00000000\"", positionsBody) + Assert.Contains("1063.68", positionsBody) + + [] + member _.``buying an unknown code with an unavailable quote does not create a position``() = + let fundId = createFund () + + let probe = + BondTradeSupport.failingQuoteProbe ( + MarketDataCollectorUnavailable "AKShare collector failed: no valid quote for 999999" + ) + + let status, body = + buy probe fundId """{"instrumentCode":"999999","quantity":"10"}""" (fixture.Key "bond-trade-unknown") + + Assert.Equal(503, status) + Assert.Contains("MARKET_DATA_UNAVAILABLE", body) + + let status, positionsBody = readPositions probe fundId + Assert.Equal(200, status) + Assert.DoesNotContain("999999", positionsBody) + Assert.Contains("\"positions\":[]", positionsBody) + + [] + member _.``buying with a non-positive quantity is rejected without a position``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let status, body = + buy probe fundId """{"instrumentCode":"110075","quantity":"0"}""" (fixture.Key "bond-trade-zero") + + Assert.Equal(400, status) + Assert.Contains("INVALID_BOND_TRADE_REQUEST", body) + + let _, positionsBody = readPositions probe fundId + Assert.Contains("\"positions\":[]", positionsBody) + + [] + member _.``buying with a malformed code is rejected``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let status, body = + buy probe fundId """{"instrumentCode":"abc","quantity":"10"}""" (fixture.Key "bond-trade-badcode") + + Assert.Equal(400, status) + Assert.Contains("six digits", body) + + [] + member _.``replaying a buy with the same idempotency key does not double the position``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + let key = fixture.Key "bond-trade-replay" + + let status, _ = buy probe fundId """{"instrumentCode":"110075","quantity":"10"}""" key + Assert.Equal(201, status) + + let status, _ = buy probe fundId """{"instrumentCode":"110075","quantity":"10"}""" key + Assert.Equal(200, status) + + let _, positionsBody = readPositions probe fundId + Assert.Contains("\"quantity\":\"10.00000000\"", positionsBody) + Assert.DoesNotContain("\"quantity\":\"20.00000000\"", positionsBody) + + [] + member _.``buying a fund that does not exist answers 404``() = + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let status, body = + buy probe (Guid.NewGuid()) """{"instrumentCode":"110075","quantity":"10"}""" (fixture.Key "bond-trade-missing") + + Assert.Equal(404, status) + Assert.Contains("FUND_NOT_FOUND", body) diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index 65352a7..dffe48f 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -35,6 +35,7 @@ + diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 25d7867..3a9ec61 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -657,6 +657,58 @@ module ConfirmationBoundaryTests = Assert.Equal("100", updated.stockPositions[0].quantity) Assert.False(updated.stockPositionsInFlight) + [] + let ``bond buy failure keeps no position and surfaces a message`` () = + let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + let model = + { init () with + token = "qa-token" + createdFund = Some(FundBoundaryTests.sampleFund fundId) + bondCode = "999999" + bondTradeQuantity = "10" + bondTradeInFlight = true + bondTradeSeq = 1 } + + let updated, _ = update (BondTradeFailed(1, fundId, "collector exited with code 2")) model + + Assert.Empty updated.bondPositions + Assert.Contains("买入失败", updated.bondTradeMessage |> Option.defaultValue "") + Assert.False(updated.bondTradeInFlight) + + [] + let ``bond positions read completion stores decoded rows`` () = + let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + let model = + { init () with + createdFund = Some(FundBoundaryTests.sampleFund fundId) + bondPositionsReadSeq = 1 + bondPositionsInFlight = true } + + let raw : RawBondPositions = + { + fundId = fundId + positions = + [| + { + instrumentCode = "110075" + bondName = box "南航转债" + quantity = "10" + costCash = "1063.68" + lastTradedAt = "2026-09-21T08:00:00Z" + } + |] + } + + let updated, _ = update (BondPositionsReadCompleted(1, fundId, raw)) model + + Assert.Equal(1, updated.bondPositions.Length) + Assert.Equal("110075", updated.bondPositions[0].instrumentCode) + Assert.Equal(Some "南航转债", updated.bondPositions[0].bondName) + Assert.Equal("10", updated.bondPositions[0].quantity) + Assert.False(updated.bondPositionsInFlight) + [] let ``order decode keeps decimal strings and decodes option fields`` () = let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) -- cgit v1.2.3