diff options
Diffstat (limited to 'tests/FundLab.Api.Tests/BondTradeTests.fs')
| -rw-r--r-- | tests/FundLab.Api.Tests/BondTradeTests.fs | 170 |
1 files changed, 170 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) |
