namespace FundLab.Api.Tests open System open System.Threading open Xunit open FundLab.Api module NavSeriesProbeSupport = let payload (code: string) (observations: (string * string * string option) list) = let body = observations |> List.map (fun (date, nav, accumulated) -> let accumulatedText = match accumulated with | Some value -> sprintf "\"%s\"" value | None -> "null" sprintf "{\"nav_date\":\"%s\",\"published_at\":null,\"nav\":\"%s\",\"accumulated_nav\":%s,\"daily_return\":null}" date nav accumulatedText) |> String.concat "," sprintf "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"nav\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"observations\":[%s]}" code body [] type NavSeriesProbeTests(fixture: PostgresFixture) = let repository () = FundRepository(fixture.ConnectionString) let stubDateProbe = { new INavDateProbe with member _.RecentNavDates(_, _, _) = Ok [] } let appWithSeries (probe: INavSeriesProbe) = App.createApplicationWithProbes (repository ()) { NavDates = stubDateProbe NavSeries = probe BondQuotes = NavDateProbeSupport.stubBondQuoteProbe StockQuotes = NavDateProbeSupport.stubStockQuoteProbe StockDaily = NavDateProbeSupport.stubStockDailyProbe } [] member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() = let json = NavSeriesProbeSupport.payload "000001" [ "2026-09-16", "1.1205", Some "2.5000" "2026-09-21", "1.1902", None "2026-09-16", "1.1205", Some "2.5000" "2026-09-18", "1.1500", Some "2.5300" "2026-09-17", "0.0000", None "2026-09-15", "1.1000", Some "2.4800" ] let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok json)) :> INavSeriesProbe match probe.RecentNavSeries("000001", 4, CancellationToken.None) with | Ok points -> Assert.Equal( [ DateOnly(2026, 9, 21); DateOnly(2026, 9, 18); DateOnly(2026, 9, 16); DateOnly(2026, 9, 15) ], points |> List.map (fun point -> point.NavDate) ) Assert.Equal(4, List.length points) Assert.Equal(Some 2.53m, (points |> List.find (fun point -> point.NavDate = DateOnly(2026, 9, 18))).AccumulatedNav) Assert.Equal(None, (points |> List.head).AccumulatedNav) | Error failure -> failwithf "expected NAV series, got %A" failure [] member _.``series memoises successful lookups per code and day``() = let mutable calls = 0 let collector = NavDateProbeSupport.collectorReturning (fun () -> calls <- calls + 1 Ok(NavSeriesProbeSupport.payload "000001" [ "2026-09-21", "1.1902", None ])) let probe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe let first = probe.RecentNavSeries("000001", 30, CancellationToken.None) let second = probe.RecentNavSeries("000001", 30, CancellationToken.None) Assert.Equal(1, calls) Assert.Equal(first |> Result.map (List.map (fun point -> point.NavDate)), second |> Result.map (List.map (fun point -> point.NavDate))) [] member _.``series returns an empty list when no usable nav remains``() = let json = NavSeriesProbeSupport.payload "000001" [ "2026-09-21", "0.0000", None; "2026-09-18", "0.0000", None ] let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok json)) :> INavSeriesProbe match probe.RecentNavSeries("000001", 30, CancellationToken.None) with | Ok points -> Assert.Empty points | Error failure -> failwithf "expected empty NAV series, got %A" failure [] member _.``series rejects a malformed fund code without touching the collector``() = let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) :> INavSeriesProbe match probe.RecentNavSeries("abc", 30, CancellationToken.None) with | Error (InvalidMarketDataRequest message) -> Assert.Contains("six digits", message) | other -> failwithf "expected InvalidMarketDataRequest, got %A" other [] member _.``series maps collector failures to an unavailable reason``() = let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Error "collector exited with code 2")) :> INavSeriesProbe match probe.RecentNavSeries("000001", 30, CancellationToken.None) with | Error (MarketDataCollectorUnavailable message) -> Assert.Contains("code 2", message) | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other [] member _.``series maps malformed payloads to an invalid payload reason``() = let probe = AkshareNavSeriesProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> INavSeriesProbe match probe.RecentNavSeries("000001", 30, CancellationToken.None) with | Error (InvalidMarketDataPayload _) -> () | other -> failwithf "expected InvalidMarketDataPayload, got %A" other [] member _.``market nav-series endpoint returns the probe points``() = let probe = { new INavSeriesProbe with member _.RecentNavSeries(code, limit, _) = Assert.Equal("000001", code) Assert.Equal(30, limit) Ok [ { NavDate = DateOnly(2026, 9, 21); Nav = 1.1902m; AccumulatedNav = None } { NavDate = DateOnly(2026, 9, 18); Nav = 1.15m; AccumulatedNav = Some 2.53m } ] } let status, body = PersistenceTestHelpers.invoke (appWithSeries probe) "GET" "/api/market/nav-series?code=000001" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, status) Assert.Contains("\"code\":\"000001\"", body) Assert.Contains("\"navDate\":\"2026-09-21\"", body) Assert.Contains("1.1902", body) Assert.Contains("\"navDate\":\"2026-09-18\"", body) Assert.Contains("2.53", body) [] member _.``market nav-series endpoint passes the requested limit``() = let probe = { new INavSeriesProbe with member _.RecentNavSeries(_, limit, _) = Assert.Equal(3, limit) Ok [ { NavDate = DateOnly(2026, 9, 21); Nav = 1.1902m; AccumulatedNav = None } ] } let status, _ = PersistenceTestHelpers.invoke (appWithSeries probe) "GET" "/api/market/nav-series?code=000001&limit=3" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, status) [] member _.``market nav-series endpoint returns an empty list for no usable points``() = let probe = { new INavSeriesProbe with member _.RecentNavSeries(_, _, _) = Ok [] } let status, body = PersistenceTestHelpers.invoke (appWithSeries probe) "GET" "/api/market/nav-series?code=000001" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, status) Assert.Contains("\"points\":[]", body) [] member _.``market nav-series endpoint answers 503 when the probe fails``() = let probe = { new INavSeriesProbe with member _.RecentNavSeries(_, _, _) = Error(MarketDataCollectorUnavailable "collector exited with code 2") } let status, body = PersistenceTestHelpers.invoke (appWithSeries probe) "GET" "/api/market/nav-series?code=000001" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(503, status) Assert.Contains("MARKET_DATA_UNAVAILABLE", body) Assert.Contains("code 2", body) [] member _.``market nav-series endpoint rejects a malformed code with 400``() = let probe = { new INavSeriesProbe with member _.RecentNavSeries(_, _, _) = Error(InvalidMarketDataRequest "fund code must contain exactly six digits") } let status, body = PersistenceTestHelpers.invoke (appWithSeries probe) "GET" "/api/market/nav-series?code=abc" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(400, status) Assert.Contains("INVALID_MARKET_DATA_REQUEST", body) [] member _.``market nav-series endpoint rejects a malformed limit with 400``() = let probe = { new INavSeriesProbe with member _.RecentNavSeries(_, _, _) = failwith "probe should not be called for an invalid limit" } let status, body = PersistenceTestHelpers.invoke (appWithSeries probe) "GET" "/api/market/nav-series?code=000001&limit=0" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(400, status) Assert.Contains("limit must be an integer between 1 and 250", body)