diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 00:56:35 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 00:56:35 +0800 |
| commit | 70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 (patch) | |
| tree | c4b8ce56b1867d8b4c744422fef3e965eb2aadda /tests/FundLab.Api.Tests/NavSeriesProbeTests.fs | |
| parent | 0597ac74757e223c17a6477ff49d69a559168279 (diff) | |
| download | fund-lab-70e1ad4fc8ba248f835d415faade8a91ac3dc1f8.tar.gz | |
Add real NAV series multi-day returns (3d-13)
Diffstat (limited to 'tests/FundLab.Api.Tests/NavSeriesProbeTests.fs')
| -rw-r--r-- | tests/FundLab.Api.Tests/NavSeriesProbeTests.fs | 237 |
1 files changed, 237 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs new file mode 100644 index 0000000..14fd8e8 --- /dev/null +++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs @@ -0,0 +1,237 @@ +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 + +[<Collection("postgres")>] +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 } + + [<Fact>] + 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 list>( + [ 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<decimal option>(Some 2.53m, (points |> List.find (fun point -> point.NavDate = DateOnly(2026, 9, 18))).AccumulatedNav) + Assert.Equal<decimal option>(None, (points |> List.head).AccumulatedNav) + | Error failure -> failwithf "expected NAV series, got %A" failure + + [<Fact>] + 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))) + + [<Fact>] + 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 + + [<Fact>] + 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 + + [<Fact>] + 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 + + [<Fact>] + 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 + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) |
