namespace FundLab.Api.Tests open System.Threading open Xunit open FundLab.Api module StockQuoteProbeSupport = let payload (code: string) (name: string option) (price: string option) = let optionalText = function | Some value -> sprintf "\"%s\"" value | None -> "null" sprintf "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"stock-quote\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"quote\":{\"name\":%s,\"price\":%s,\"currency\":\"CNY\"}}" code (optionalText name) (optionalText price) let collectorReturning (fetch: unit -> Result) = { new IMarketDataCollector with member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = Ok "" member _.FetchBondQuote(_, _) = Ok "" member _.FetchStockQuote(_, _) = fetch () member _.FetchStockDaily(_, _, _) = Ok "" } let stubProbe (getQuote: string * CancellationToken -> Result) = { new IStockQuoteProbe with member _.GetQuote(code, token) = getQuote (code, token) } [] type StockQuoteProbeTests(fixture: PostgresFixture) = let repository () = FundRepository(fixture.ConnectionString) let appWithQuote (probe: IStockQuoteProbe) = App.createApplicationWithProbes (repository ()) { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe BondQuotes = NavDateProbeSupport.stubBondQuoteProbe StockQuotes = probe StockDaily = NavDateProbeSupport.stubStockDailyProbe } [] member _.``probe parses name, price and currency``() = let json = StockQuoteProbeSupport.payload "600519" (Some "贵州茅台") (Some "1252.57") let probe = AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe match probe.GetQuote("600519", CancellationToken.None) with | Ok quote -> Assert.Equal("600519", quote.Code) Assert.Equal(Some "贵州茅台", quote.Name) Assert.Equal(Some 1252.57m, quote.Price) Assert.Equal("CNY", quote.Currency) | Error failure -> failwithf "expected a stock quote, got %A" failure [] member _.``probe parses market, adjust and suspension flags``() = let json = "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"stock-quote\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"000001\"},\"quote\":{\"name\":\"平安银行\",\"price\":\"11.2300\",\"currency\":\"CNY\",\"market\":\"sz\",\"adjust\":\"none\",\"suspended\":true}}" let probe = AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe match probe.GetQuote("000001", CancellationToken.None) with | Ok quote -> Assert.Equal(Some "sz", quote.Market) Assert.Equal(Some "none", quote.Adjust) Assert.Equal(Some true, quote.Suspended) | Error failure -> failwithf "expected a stock quote, got %A" failure [] member _.``probe keeps a missing name missing instead of fabricating data``() = let json = StockQuoteProbeSupport.payload "600519" None (Some "1252.57") let probe = AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe match probe.GetQuote("600519", CancellationToken.None) with | Ok quote -> Assert.Equal(None, quote.Name) Assert.Equal(Some 1252.57m, quote.Price) | Error failure -> failwithf "expected a stock quote, got %A" failure [] member _.``probe rejects a malformed stock code without touching the collector``() = let probe = AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) :> IStockQuoteProbe match probe.GetQuote("abc", CancellationToken.None) with | Error(InvalidMarketDataRequest message) -> Assert.Contains("six digits", message) | other -> failwithf "expected InvalidMarketDataRequest, got %A" other [] member _.``probe surfaces a no valid quote collector failure and does not cache it``() = let mutable calls = 0 let collector = StockQuoteProbeSupport.collectorReturning (fun () -> calls <- calls + 1 Error "AKShare collector failed: no valid quote for 000000") let probe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe match probe.GetQuote("000000", CancellationToken.None) with | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid quote for 000000", message) | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other match probe.GetQuote("000000", 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 = AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IStockQuoteProbe match probe.GetQuote("600519", CancellationToken.None) with | Error(InvalidMarketDataPayload _) -> () | other -> failwithf "expected InvalidMarketDataPayload, got %A" other [] member _.``probe memoises successful lookups per stock code``() = let mutable calls = 0 let collector = StockQuoteProbeSupport.collectorReturning (fun () -> calls <- calls + 1 Ok(StockQuoteProbeSupport.payload "600519" (Some "贵州茅台") (Some "1252.57"))) let probe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe let first = probe.GetQuote("600519", CancellationToken.None) let second = probe.GetQuote("600519", CancellationToken.None) Assert.Equal(1, calls) Assert.Equal(first |> Result.map (fun quote -> quote.Price), second |> Result.map (fun quote -> quote.Price)) [] member _.``market stock-quote endpoint returns the probe record``() = let probe = StockQuoteProbeSupport.stubProbe (fun (code, _) -> Assert.Equal("600519", code) Ok { Code = "600519" Name = Some "贵州茅台" Price = Some 1252.57m Currency = "CNY" Market = Some "sh" Adjust = Some "none" Suspended = Some false }) let status, body = PersistenceTestHelpers.invoke (appWithQuote probe) "GET" "/api/market/stock-quote?code=600519" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, status) Assert.Contains("\"code\":\"600519\"", body) Assert.Contains("\"name\":\"贵州茅台\"", body) Assert.Contains("1252.57", body) Assert.Contains("\"currency\":\"CNY\"", body) [] member _.``market stock-quote endpoint answers 503 when the probe fails``() = let probe = StockQuoteProbeSupport.stubProbe (fun _ -> Error(MarketDataCollectorUnavailable "collector exited with code 2")) let status, body = PersistenceTestHelpers.invoke (appWithQuote probe) "GET" "/api/market/stock-quote?code=000000" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(503, status) Assert.Contains("MARKET_DATA_UNAVAILABLE", body) Assert.Contains("code 2", body) [] member _.``market stock-quote endpoint rejects a malformed code with 400``() = let probe = StockQuoteProbeSupport.stubProbe (fun _ -> Error(InvalidMarketDataRequest "stock code must contain exactly six digits")) let status, body = PersistenceTestHelpers.invoke (appWithQuote probe) "GET" "/api/market/stock-quote?code=abc" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(400, status) Assert.Contains("INVALID_MARKET_DATA_REQUEST", body)