summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 00:56:35 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 00:56:35 +0800
commit70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 (patch)
treec4b8ce56b1867d8b4c744422fef3e965eb2aadda /tests/FundLab.Api.Tests
parent0597ac74757e223c17a6477ff49d69a559168279 (diff)
downloadfund-lab-70e1ad4fc8ba248f835d415faade8a91ac3dc1f8.tar.gz
Add real NAV series multi-day returns (3d-13)
Diffstat (limited to 'tests/FundLab.Api.Tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/NavDateProbeTests.fs6
-rw-r--r--tests/FundLab.Api.Tests/NavSeriesProbeTests.fs237
3 files changed, 243 insertions, 1 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index a77e503..07db95f 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -30,6 +30,7 @@
<Compile Include="InvestmentPlanTests.fs" />
<Compile Include="RebalanceTests.fs" />
<Compile Include="NavDateProbeTests.fs" />
+ <Compile Include="NavSeriesProbeTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
index 0f7e506..6197950 100644
--- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs
+++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
@@ -71,8 +71,12 @@ type RealAkshareFactAttribute() =
type NavDateProbeTests(fixture: PostgresFixture) =
let repository () = FundRepository(fixture.ConnectionString)
+ let stubSeriesProbe =
+ { new INavSeriesProbe with
+ member _.RecentNavSeries(_, _, _) = Ok [] }
+
let appWithProbe (probe: INavDateProbe) =
- App.createApplicationWithNavDateProbe (repository ()) probe
+ App.createApplicationWithProbes (repository ()) { NavDates = probe; NavSeries = stubSeriesProbe }
[<Fact>]
member _.``probe parses, dedupes, sorts descending and truncates``() =
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)