summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 04:45:10 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 04:45:10 +0800
commit0183b86f18d9897ff9487e6d864898954397991d (patch)
tree245c8f197f5803802dd630f278e9f2a08affa4ec /tests
parentb6008d67232756d743699472865025159f6ec533 (diff)
downloadfund-lab-0183b86f18d9897ff9487e6d864898954397991d.tar.gz
Add bond buy to holdings minimal vertical slice (3d-22)
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/BondTradeTests.fs170
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs52
3 files changed, 223 insertions, 0 deletions
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 }
+
+[<Collection("postgres")>]
+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" ]
+ ""
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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 @@
<Compile Include="StockQuoteProbeTests.fs" />
<Compile Include="StockDailyProbeTests.fs" />
<Compile Include="StockTradeTests.fs" />
+ <Compile Include="BondTradeTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
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
@@ -658,6 +658,58 @@ module ConfirmationBoundaryTests =
Assert.False(updated.stockPositionsInFlight)
[<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
let ``order decode keeps decimal strings and decodes option fields`` () =
let confirmed = Api.decodeOrder (sampleConfirmedRaw ())
let pending = Api.decodeOrder (samplePendingRaw ())