namespace FundLab.Api.Tests open System open System.Threading open Xunit open FundLab.Api module BondQuoteProbeSupport = let private optionalText = function | Some value -> sprintf "\"%s\"" value | None -> "null" let payloadWith (code: string) (name: string option) (price: string option) (maturity: string option) (cleanPrice: string option) (accruedInterest: string option) (date: string option) (sourceRevision: string) = sprintf "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"bond-quote\",\"source\":\"akshare\",\"source_revision\":\"%s\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"quote\":{\"name\":%s,\"price\":%s,\"clean_price\":%s,\"accrued_interest\":%s,\"date\":%s,\"maturity_date\":%s}}" sourceRevision code (optionalText name) (optionalText price) (optionalText cleanPrice) (optionalText accruedInterest) (optionalText date) (optionalText maturity) let payload (code: string) (name: string option) (price: string option) (maturity: string option) = payloadWith code name price maturity None None None "akshare-test/eastmoney" let collectorReturning (fetch: unit -> Result) = { new IMarketDataCollector with member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = Ok "" member _.FetchBondQuote(_, _) = fetch () member _.FetchStockQuote(_, _) = Ok "" member _.FetchStockDaily(_, _, _) = Ok "" } let stubProbe (getQuote: string * CancellationToken -> Result) = { new IBondQuoteProbe with member _.GetQuote(code, token) = getQuote (code, token) } [] type BondQuoteProbeTests(fixture: PostgresFixture) = let repository () = FundRepository(fixture.ConnectionString) let appWithQuote (probe: IBondQuoteProbe) = App.createApplicationWithProbes (repository ()) { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe BondQuotes = probe StockQuotes = NavDateProbeSupport.stubStockQuoteProbe StockDaily = NavDateProbeSupport.stubStockDailyProbe } [] member _.``probe parses name, price and maturity date``() = let json = BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") (Some "101.5000") (Some "2026-06-03") let probe = AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe match probe.GetQuote("123121", CancellationToken.None) with | Ok quote -> Assert.Equal("123121", quote.Code) Assert.Equal(Some "帝尔转债", quote.Name) Assert.Equal(Some 101.5m, quote.Price) Assert.Equal(Some(DateOnly(2026, 6, 3)), quote.MaturityDate) | Error failure -> failwithf "expected a bond quote, got %A" failure [] member _.``probe keeps missing fields missing instead of fabricating data``() = let json = BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") None None let probe = AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe match probe.GetQuote("123121", CancellationToken.None) with | Ok quote -> Assert.Equal(Some "帝尔转债", quote.Name) Assert.Equal(None, quote.Price) Assert.Equal(None, quote.MaturityDate) | Error failure -> failwithf "expected a bond quote, got %A" failure [] member _.``probe parses a spot-shaped payload where maturity is unknown``() = let json = BondQuoteProbeSupport.payload "110075" (Some "南航转债") (Some "106.368") None let probe = AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe match probe.GetQuote("110075", CancellationToken.None) with | Ok quote -> Assert.Equal("110075", quote.Code) Assert.Equal(Some "南航转债", quote.Name) Assert.Equal(Some 106.368m, quote.Price) Assert.Equal(None, quote.MaturityDate) | Error failure -> failwithf "expected a bond quote, got %A" failure [] member _.``probe parses clean price, accrued interest and date when present``() = let json = BondQuoteProbeSupport.payloadWith "019547" None (Some "119.858") None (Some "119.858") (Some "0.5000") (Some "2026-09-21") "akshare-test/eastmoney/hs-bond-daily" let probe = AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe match probe.GetQuote("019547", CancellationToken.None) with | Ok quote -> Assert.Equal(Some 119.858m, quote.Price) Assert.Equal(Some 119.858m, quote.CleanPrice) Assert.Equal(Some 0.5m, quote.AccruedInterest) Assert.Equal(Some(DateOnly(2026, 9, 21)), quote.Date) Assert.Equal("akshare-test/eastmoney/hs-bond-daily", quote.SourceRevision) | Error failure -> failwithf "expected a bond quote, got %A" failure [] member _.``probe rejects a payload whose code disagrees with the request``() = let json = BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") (Some "101.5000") None let probe = AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe match probe.GetQuote("110075", CancellationToken.None) with | Error(InvalidMarketDataPayload message) -> Assert.Contains("does not match requested code", message) | other -> failwithf "expected InvalidMarketDataPayload, got %A" other [] member _.``probe surfaces a no valid quote collector failure``() = let probe = AkshareBondQuoteProbe( BondQuoteProbeSupport.collectorReturning (fun () -> Error "AKShare collector failed: no valid bond quote for 999999") ) :> IBondQuoteProbe match probe.GetQuote("999999", CancellationToken.None) with | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid bond quote for 999999", message) | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other [] member _.``probe rejects a malformed bond code without touching the collector``() = let probe = AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) :> IBondQuoteProbe match probe.GetQuote("abc", CancellationToken.None) with | Error(InvalidMarketDataRequest message) -> Assert.Contains("six digits", message) | other -> failwithf "expected InvalidMarketDataRequest, got %A" other [] member _.``probe maps collector failures to an unavailable reason and does not cache them``() = let mutable calls = 0 let collector = BondQuoteProbeSupport.collectorReturning (fun () -> calls <- calls + 1 Error "collector exited with code 2") let probe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe match probe.GetQuote("123121", CancellationToken.None) with | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("code 2", message) | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other match probe.GetQuote("123121", CancellationToken.None) with | Error(MarketDataCollectorUnavailable _) -> () | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other Assert.Equal(2, calls) [] member _.``probe maps malformed payloads to an invalid payload reason``() = let probe = AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IBondQuoteProbe match probe.GetQuote("123121", CancellationToken.None) with | Error(InvalidMarketDataPayload _) -> () | other -> failwithf "expected InvalidMarketDataPayload, got %A" other [] member _.``probe memoises successful lookups per bond code``() = let mutable calls = 0 let collector = BondQuoteProbeSupport.collectorReturning (fun () -> calls <- calls + 1 Ok(BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") None None)) let probe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe let first = probe.GetQuote("123121", CancellationToken.None) let second = probe.GetQuote("123121", CancellationToken.None) Assert.Equal(1, calls) Assert.Equal(first |> Result.map (fun quote -> quote.Code), second |> Result.map (fun quote -> quote.Code)) [] member _.``market bond-quote endpoint returns the probe record``() = let probe = BondQuoteProbeSupport.stubProbe (fun (code, _) -> Assert.Equal("123121", code) Ok { Code = "123121" SourceRevision = "akshare-test/eastmoney/hs-cov-spot" Name = Some "帝尔转债" Price = Some 101.5m CleanPrice = Some 101.5m AccruedInterest = None Date = None MaturityDate = Some(DateOnly(2026, 6, 3)) ParValue = None IssuePrice = None ValueDate = None ListingDate = None PublishDate = None PayInterestDay = None CouponRate = None CouponRateExplain = None BondExpireYears = None Rating = None DataStatus = None }) let status, body = PersistenceTestHelpers.invoke (appWithQuote probe) "GET" "/api/market/bond-quote?code=123121" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, status) Assert.Contains("\"code\":\"123121\"", body) Assert.Contains("\"name\":\"帝尔转债\"", body) Assert.Contains("101.5", body) Assert.Contains("\"maturityDate\":\"2026-06-03\"", body) [] member _.``market bond-quote endpoint answers 503 when the probe fails``() = let probe = BondQuoteProbeSupport.stubProbe (fun _ -> Error(MarketDataCollectorUnavailable "collector exited with code 2")) let status, body = PersistenceTestHelpers.invoke (appWithQuote probe) "GET" "/api/market/bond-quote?code=123121" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(503, status) Assert.Contains("MARKET_DATA_UNAVAILABLE", body) Assert.Contains("code 2", body) [] member _.``market bond-quote endpoint rejects a malformed code with 400``() = let probe = BondQuoteProbeSupport.stubProbe (fun _ -> Error(InvalidMarketDataRequest "bond code must contain exactly six digits")) let status, body = PersistenceTestHelpers.invoke (appWithQuote probe) "GET" "/api/market/bond-quote?code=abc" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(400, status) Assert.Contains("INVALID_MARKET_DATA_REQUEST", body)