namespace FundLab.Api.Tests open System open System.Threading open Xunit open FundLab.Api module StockDailyProbeSupport = let payload (code: string) (observations: (string * string * string option * string option) list) = let body = observations |> List.map (fun (date, close, volume, amount) -> let optionalText = function | Some value -> sprintf "\"%s\"" value | None -> "null" sprintf "{\"date\":\"%s\",\"close\":\"%s\",\"volume\":%s,\"amount\":%s}" date close (optionalText volume) (optionalText amount)) |> String.concat "," sprintf "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"stock-daily\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"observations\":[%s]}" code body let collectorReturning (fetch: unit -> Result) = { new IMarketDataCollector with member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = Ok "" member _.FetchBondQuote(_, _) = Ok "" member _.FetchStockQuote(_, _) = Ok "" member _.FetchStockDaily(_, _, _) = fetch () } let stubProbe (recent: string * int * CancellationToken -> Result) = { new IStockDailyProbe with member _.RecentDaily(code, days, token) = recent (code, days, token) } [] type StockDailyProbeTests(fixture: PostgresFixture) = let repository () = FundRepository(fixture.ConnectionString) let appWithDaily (probe: IStockDailyProbe) = App.createApplicationWithProbes (repository ()) { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe BondQuotes = NavDateProbeSupport.stubBondQuoteProbe StockQuotes = NavDateProbeSupport.stubStockQuoteProbe StockDaily = probe } [] member _.``probe decodes observations ascending and de-duplicates dates``() = let json = StockDailyProbeSupport.payload "600519" [ "2026-09-21", "1252.57", Some "2501689.0", Some "3135910045.0" "2026-09-17", "1266.98", Some "1755380.0", Some "2217338283.0" "2026-09-18", "1257.12", Some "2489087.0", Some "3135849108.0" "2026-09-17", "1266.98", Some "1755380.0", Some "2217338283.0" ] let probe = AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockDailyProbe match probe.RecentDaily("600519", 5, CancellationToken.None) with | Ok observations -> Assert.Equal( [ DateOnly(2026, 9, 17); DateOnly(2026, 9, 18); DateOnly(2026, 9, 21) ], observations |> List.map (fun observation -> observation.BarDate) ) Assert.Equal(3, List.length observations) Assert.Equal(Some 3135910045.0m, (observations |> List.last).Amount) | Error failure -> failwithf "expected daily observations, got %A" failure [] member _.``probe rejects a payload whose code disagrees with the request``() = let json = StockDailyProbeSupport.payload "000001" [ "2026-09-21", "1252.57", None, None ] let probe = AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockDailyProbe match probe.RecentDaily("600519", 5, CancellationToken.None) with | Error(InvalidMarketDataPayload message) -> Assert.Contains("does not match requested code", message) | other -> failwithf "expected InvalidMarketDataPayload, got %A" other [] member _.``probe rejects a malformed stock code without touching the collector``() = let probe = AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) :> IStockDailyProbe match probe.RecentDaily("abc", 5, CancellationToken.None) with | Error(InvalidMarketDataRequest message) -> Assert.Contains("six digits", message) | other -> failwithf "expected InvalidMarketDataRequest, got %A" other [] member _.``probe surfaces a no valid history collector failure``() = let probe = AkshareStockDailyProbe( StockDailyProbeSupport.collectorReturning (fun () -> Error "AKShare collector failed: no valid daily history for 999999") ) :> IStockDailyProbe match probe.RecentDaily("999999", 5, CancellationToken.None) with | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid daily history for 999999", message) | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other [] member _.``probe maps malformed payloads to an invalid payload reason``() = let probe = AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IStockDailyProbe match probe.RecentDaily("600519", 5, CancellationToken.None) with | Error(InvalidMarketDataPayload _) -> () | other -> failwithf "expected InvalidMarketDataPayload, got %A" other [] member _.``market stock-daily endpoint returns the probe observations``() = let probe = StockDailyProbeSupport.stubProbe (fun (code, days, _) -> Assert.Equal("600519", code) Assert.Equal(5, days) Ok [ { BarDate = DateOnly(2026, 9, 18); Close = 1257.12m; Volume = Some 2489087m; Amount = Some 3135849108m } { BarDate = DateOnly(2026, 9, 21); Close = 1252.57m; Volume = Some 2501689m; Amount = Some 3135910045m } ]) let status, body = PersistenceTestHelpers.invoke (appWithDaily probe) "GET" "/api/market/stock-daily?code=600519&days=5" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, status) Assert.Contains("\"code\":\"600519\"", body) Assert.Contains("\"date\":\"2026-09-21\"", body) Assert.Contains("1252.57", body) [] member _.``market stock-daily endpoint answers 503 when the probe fails``() = let probe = StockDailyProbeSupport.stubProbe (fun _ -> Error(MarketDataCollectorUnavailable "AKShare collector failed: no valid daily history for 999999")) let status, body = PersistenceTestHelpers.invoke (appWithDaily probe) "GET" "/api/market/stock-daily?code=999999&days=5" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(503, status) Assert.Contains("MARKET_DATA_UNAVAILABLE", body) [] member _.``market stock-daily endpoint rejects a malformed code with 400``() = let probe = StockDailyProbeSupport.stubProbe (fun _ -> Error(InvalidMarketDataRequest "stock code must contain exactly six digits")) let status, body = PersistenceTestHelpers.invoke (appWithDaily probe) "GET" "/api/market/stock-daily?code=abc&days=5" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(400, status) Assert.Contains("INVALID_MARKET_DATA_REQUEST", body) [] member _.``market stock-daily endpoint rejects an out-of-range days with 400``() = let probe = StockDailyProbeSupport.stubProbe (fun _ -> failwith "probe should not be called for invalid days") let status, body = PersistenceTestHelpers.invoke (appWithDaily probe) "GET" "/api/market/stock-daily?code=600519&days=999" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(400, status) Assert.Contains("days must be an integer between 1 and 30", body)