namespace FundLab.Api.Tests open System open System.Diagnostics open System.IO open System.Text.Json open System.Threading open Xunit open FundLab.Api module NavDateProbeSupport = let navPayloadJson (code: string) (observations: (string * string) list) = let body = observations |> List.map (fun (date, nav) -> sprintf "{\"nav_date\":\"%s\",\"published_at\":null,\"nav\":\"%s\",\"accumulated_nav\":null,\"daily_return\":null}" date nav) |> 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 let collectorReturning (fetch: unit -> Result) = { new IMarketDataCollector with member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = fetch () member _.FetchBondQuote(_, _) = Ok "" member _.FetchStockQuote(_, _) = Ok "" member _.FetchStockDaily(_, _, _) = Ok "" } let stubBondQuoteProbe = { new IBondQuoteProbe with member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "bond probe not configured for this test") } let stubStockQuoteProbe = { new IStockQuoteProbe with member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "stock probe not configured for this test") } let stubStockDailyProbe = { new IStockDailyProbe with member _.RecentDaily(_, _, _) = Error(InvalidMarketDataRequest "stock daily probe not configured for this test") } let stubNavDateProbe = { new INavDateProbe with member _.RecentNavDates(_, _, _) = Ok [] } let stubNavSeriesProbe = { new INavSeriesProbe with member _.RecentNavSeries(_, _, _) = Ok [] } let realAksharePython () = let configured = Environment.GetEnvironmentVariable("FUND_LAB_AKSHARE_PYTHON") if String.IsNullOrWhiteSpace configured then None elif File.Exists configured then Some configured else let path = Environment.GetEnvironmentVariable("PATH") |> Option.ofObj |> Option.defaultValue "" path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) |> Array.tryPick (fun directory -> let candidate = Path.Combine(directory, configured) if File.Exists candidate then Some candidate else None) let repositoryRoot () = let mutable directory = DirectoryInfo(AppContext.BaseDirectory) while not (isNull directory) && not (File.Exists(Path.Combine(directory.FullName, "scripts", "probe_akshare_nav_dates.py"))) do directory <- directory.Parent if isNull directory then failwith "could not locate scripts/probe_akshare_nav_dates.py from the test output directory" else directory.FullName /// xUnit has no first-class runtime skip in 2.x, so this fact sets Skip during /// discovery when the host has no reachable AKShare interpreter configured. /// That keeps the live probe detectable ("skipped, not faked") instead of red. type RealAkshareFactAttribute() = inherit FactAttribute() do if (NavDateProbeSupport.realAksharePython ()).IsNone then base.Skip <- "set FUND_LAB_AKSHARE_PYTHON to a python interpreter with akshare installed to run the live probe" [] type NavDateProbeTests(fixture: PostgresFixture) = let repository () = FundRepository(fixture.ConnectionString) let stubSeriesProbe = { new INavSeriesProbe with member _.RecentNavSeries(_, _, _) = Ok [] } let appWithProbe (probe: INavDateProbe) = App.createApplicationWithProbes (repository ()) { NavDates = probe NavSeries = stubSeriesProbe BondQuotes = NavDateProbeSupport.stubBondQuoteProbe StockQuotes = NavDateProbeSupport.stubStockQuoteProbe StockDaily = NavDateProbeSupport.stubStockDailyProbe } [] member _.``probe parses, dedupes, sorts descending and truncates``() = let payload = NavDateProbeSupport.navPayloadJson "000001" [ "2026-09-16", "1.1205" "2026-09-21", "1.1902" "2026-09-16", "1.1205" "2026-09-18", "1.1500" "2026-09-17", "1.1180" "2026-09-15", "1.1000" "2026-09-11", "1.0900" ] let probe = AkshareNavDateProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok payload)) :> INavDateProbe match probe.RecentNavDates("000001", 5, CancellationToken.None) with | Ok dates -> Assert.Equal( [ DateOnly(2026, 9, 21) DateOnly(2026, 9, 18) DateOnly(2026, 9, 17) DateOnly(2026, 9, 16) DateOnly(2026, 9, 15) ], dates ) | Error failure -> failwithf "expected NAV dates, got %A" failure [] member _.``probe memoises successful lookups per code``() = let mutable calls = 0 let collector = NavDateProbeSupport.collectorReturning (fun () -> calls <- calls + 1 Ok(NavDateProbeSupport.navPayloadJson "000001" [ "2026-09-21", "1.1902" ])) let probe = AkshareNavDateProbe(collector) :> INavDateProbe let first = probe.RecentNavDates("000001", 5, CancellationToken.None) let second = probe.RecentNavDates("000001", 5, CancellationToken.None) Assert.Equal(Ok [ DateOnly(2026, 9, 21) ], first) Assert.Equal(Ok [ DateOnly(2026, 9, 21) ], second) Assert.Equal(1, calls) [] member _.``probe rejects a malformed fund code without touching the collector``() = let collector = NavDateProbeSupport.collectorReturning (fun () -> failwith "collector should not be called") let probe = AkshareNavDateProbe(collector) :> INavDateProbe match probe.RecentNavDates("abc", 5, 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``() = let collector = NavDateProbeSupport.collectorReturning (fun () -> Error "AKShare is unreachable: connection timed out") let probe = AkshareNavDateProbe(collector) :> INavDateProbe match probe.RecentNavDates("000001", 5, CancellationToken.None) with | Error (MarketDataCollectorUnavailable message) -> Assert.Contains("connection timed out", message) | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other [] member _.``probe maps malformed payloads to an invalid payload reason``() = let probe = AkshareNavDateProbe(NavDateProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> INavDateProbe match probe.RecentNavDates("000001", 5, CancellationToken.None) with | Error (InvalidMarketDataPayload _) -> () | other -> failwithf "expected InvalidMarketDataPayload, got %A" other [] member _.``market nav-dates endpoint returns the probe dates``() = let probe = { new INavDateProbe with member _.RecentNavDates(code, limit, _) = Assert.Equal("000001", code) Assert.Equal(5, limit) Ok [ DateOnly(2026, 9, 21); DateOnly(2026, 9, 18) ] } let status, body = PersistenceTestHelpers.invoke (appWithProbe probe) "GET" "/api/market/nav-dates?code=000001" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(200, status) Assert.Contains("\"code\":\"000001\"", body) Assert.Contains("\"dates\":[\"2026-09-21\",\"2026-09-18\"]", body) [] member _.``market nav-dates endpoint answers 503 when the probe fails``() = let probe = { new INavDateProbe with member _.RecentNavDates(_, _, _) = Error(MarketDataCollectorUnavailable "AKShare is unreachable: connection timed out") } let status, body = PersistenceTestHelpers.invoke (appWithProbe probe) "GET" "/api/market/nav-dates?code=000001" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(503, status) Assert.Contains("MARKET_DATA_UNAVAILABLE", body) Assert.Contains("connection timed out", body) [] member _.``market nav-dates endpoint rejects a malformed code with 400``() = let probe = { new INavDateProbe with member _.RecentNavDates(_, _, _) = Error(InvalidMarketDataRequest "fund code must contain exactly six digits") } let status, body = PersistenceTestHelpers.invoke (appWithProbe probe) "GET" "/api/market/nav-dates?code=abc" [ "Authorization", "Bearer test-token" ] "" Assert.Equal(400, status) Assert.Contains("INVALID_MARKET_DATA_REQUEST", body) [] member _.``live AKShare probe returns the five most recent NAV dates``() = let python = (NavDateProbeSupport.realAksharePython ()).Value let script = Path.Combine(NavDateProbeSupport.repositoryRoot (), "scripts", "probe_akshare_nav_dates.py") let startInfo = ProcessStartInfo(python) startInfo.ArgumentList.Add script startInfo.ArgumentList.Add "--code" startInfo.ArgumentList.Add "000001" startInfo.ArgumentList.Add "--limit" startInfo.ArgumentList.Add "5" startInfo.RedirectStandardOutput <- true startInfo.RedirectStandardError <- true startInfo.UseShellExecute <- false match Environment.GetEnvironmentVariable("FUND_LAB_AKSHARE_PYTHONPATH") with | value when not (String.IsNullOrWhiteSpace value) -> startInfo.Environment["PYTHONPATH"] <- value | _ -> () use probeProcess = Process.Start startInfo let standardOutput = probeProcess.StandardOutput.ReadToEnd() let standardError = probeProcess.StandardError.ReadToEnd() probeProcess.WaitForExit 60000 |> ignore Assert.True(probeProcess.ExitCode = 0, sprintf "probe exited with %d: %s" probeProcess.ExitCode standardError) use document = JsonDocument.Parse standardOutput let dates = document.RootElement.GetProperty("dates").EnumerateArray() |> Seq.map (fun element -> element.GetString()) |> Seq.toList Assert.Equal(5, List.length dates) Assert.Equal(List.sortDescending dates, dates)