namespace FundLab.Api.Tests open System open System.Threading open Xunit open FundLab.Api module BondQuoteProbeSupport = let payload (code: string) (name: string option) (price: string option) (maturity: string option) = let optionalText = function | Some value -> sprintf "\"%s\"" value | None -> "null" sprintf "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"bond-quote\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"quote\":{\"name\":%s,\"price\":%s,\"maturity_date\":%s}}" code (optionalText name) (optionalText price) (optionalText maturity) let collectorReturning (fetch: unit -> Result) = { new IMarketDataCollector with member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = Ok "" member _.FetchBondQuote(_, _) = fetch () member _.FetchStockQuote(_, _) = 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 } [] 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 surfaces a no valid quote collector failure``() = let probe = AkshareBondQuoteProbe( BondQuoteProbeSupport.collectorReturning (fun () -> Error "AKShare collector failed: no valid quote for 999999") ) :> IBondQuoteProbe match probe.GetQuote("999999", CancellationToken.None) with | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid 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" Name = Some "帝尔转债" Price = Some 101.5m MaturityDate = Some(DateOnly(2026, 6, 3)) }) 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)