summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 01:12:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 01:12:52 +0800
commit3cc0b4dcfeed042e5a69628c6b7f45580ad1e2f9 (patch)
treeb86805c2680327412d6f5d554d4506ebfe62fc2f /tests/FundLab.Api.Tests
parent70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 (diff)
downloadfund-lab-3cc0b4dcfeed042e5a69628c6b7f45580ad1e2f9.tar.gz
Add real AKShare bond quote probe slice (3d-14)
Diffstat (limited to 'tests/FundLab.Api.Tests')
-rw-r--r--tests/FundLab.Api.Tests/BondQuoteProbeTests.fs189
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/NavDateProbeTests.fs19
-rw-r--r--tests/FundLab.Api.Tests/NavSeriesProbeTests.fs6
4 files changed, 212 insertions, 3 deletions
diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
new file mode 100644
index 0000000..a94bf58
--- /dev/null
+++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
@@ -0,0 +1,189 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Threading
+open Xunit
+open FundLab.Api
+
+module BondQuoteProbeSupport =
+ let payload (code: string) (name: string option) (price: string option) (maturity: string option) =
+ let optionalText =
+ function
+ | Some value -> sprintf "\"%s\"" value
+ | None -> "null"
+
+ sprintf
+ "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"bond-quote\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"quote\":{\"name\":%s,\"price\":%s,\"maturity_date\":%s}}"
+ code
+ (optionalText name)
+ (optionalText price)
+ (optionalText maturity)
+
+ let collectorReturning (fetch: unit -> Result<string, string>) =
+ { new IMarketDataCollector with
+ member _.Search(_, _) = Ok ""
+ member _.FetchNav(_, _) = Ok ""
+ member _.FetchBondQuote(_, _) = fetch () }
+
+ let stubProbe (getQuote: string * CancellationToken -> Result<BondQuote, MarketDataFailure>) =
+ { new IBondQuoteProbe with
+ member _.GetQuote(code, token) = getQuote (code, token) }
+
+[<Collection("postgres")>]
+type BondQuoteProbeTests(fixture: PostgresFixture) =
+ let repository () = FundRepository(fixture.ConnectionString)
+
+ let appWithQuote (probe: IBondQuoteProbe) =
+ App.createApplicationWithProbes
+ (repository ())
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = probe }
+
+ [<Fact>]
+ member _.``probe parses name, price and maturity date``() =
+ let json = BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") (Some "101.5000") (Some "2026-06-03")
+
+ let probe =
+ AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe
+
+ match probe.GetQuote("123121", CancellationToken.None) with
+ | Ok quote ->
+ Assert.Equal("123121", quote.Code)
+ Assert.Equal<string option>(Some "帝尔转债", quote.Name)
+ Assert.Equal<decimal option>(Some 101.5m, quote.Price)
+ Assert.Equal<DateOnly option>(Some(DateOnly(2026, 6, 3)), quote.MaturityDate)
+ | Error failure -> failwithf "expected a bond quote, got %A" failure
+
+ [<Fact>]
+ member _.``probe keeps missing fields missing instead of fabricating data``() =
+ let json = BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") None None
+
+ let probe =
+ AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe
+
+ match probe.GetQuote("123121", CancellationToken.None) with
+ | Ok quote ->
+ Assert.Equal(Some "帝尔转债", quote.Name)
+ Assert.Equal<decimal option>(None, quote.Price)
+ Assert.Equal<DateOnly option>(None, quote.MaturityDate)
+ | Error failure -> failwithf "expected a bond quote, got %A" failure
+
+ [<Fact>]
+ member _.``probe rejects a malformed bond code without touching the collector``() =
+ let probe =
+ AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> failwith "collector should not be called"))
+ :> IBondQuoteProbe
+
+ match probe.GetQuote("abc", CancellationToken.None) with
+ | Error(InvalidMarketDataRequest message) -> Assert.Contains("six digits", message)
+ | other -> failwithf "expected InvalidMarketDataRequest, got %A" other
+
+ [<Fact>]
+ member _.``probe maps collector failures to an unavailable reason and does not cache them``() =
+ let mutable calls = 0
+
+ let collector =
+ BondQuoteProbeSupport.collectorReturning (fun () ->
+ calls <- calls + 1
+ Error "collector exited with code 2")
+
+ let probe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe
+
+ match probe.GetQuote("123121", CancellationToken.None) with
+ | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("code 2", message)
+ | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other
+
+ match probe.GetQuote("123121", CancellationToken.None) with
+ | Error(MarketDataCollectorUnavailable _) -> ()
+ | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other
+
+ Assert.Equal(2, calls)
+
+ [<Fact>]
+ member _.``probe maps malformed payloads to an invalid payload reason``() =
+ let probe =
+ AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IBondQuoteProbe
+
+ match probe.GetQuote("123121", CancellationToken.None) with
+ | Error(InvalidMarketDataPayload _) -> ()
+ | other -> failwithf "expected InvalidMarketDataPayload, got %A" other
+
+ [<Fact>]
+ member _.``probe memoises successful lookups per bond code``() =
+ let mutable calls = 0
+
+ let collector =
+ BondQuoteProbeSupport.collectorReturning (fun () ->
+ calls <- calls + 1
+ Ok(BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") None None))
+
+ let probe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe
+
+ let first = probe.GetQuote("123121", CancellationToken.None)
+ let second = probe.GetQuote("123121", CancellationToken.None)
+
+ Assert.Equal(1, calls)
+ Assert.Equal(first |> Result.map (fun quote -> quote.Code), second |> Result.map (fun quote -> quote.Code))
+
+ [<Fact>]
+ member _.``market bond-quote endpoint returns the probe record``() =
+ let probe =
+ BondQuoteProbeSupport.stubProbe (fun (code, _) ->
+ Assert.Equal("123121", code)
+
+ Ok
+ {
+ Code = "123121"
+ Name = Some "帝尔转债"
+ Price = Some 101.5m
+ MaturityDate = Some(DateOnly(2026, 6, 3))
+ })
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithQuote probe)
+ "GET"
+ "/api/market/bond-quote?code=123121"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"code\":\"123121\"", body)
+ Assert.Contains("\"name\":\"帝尔转债\"", body)
+ Assert.Contains("101.5", body)
+ Assert.Contains("\"maturityDate\":\"2026-06-03\"", body)
+
+ [<Fact>]
+ member _.``market bond-quote endpoint answers 503 when the probe fails``() =
+ let probe =
+ BondQuoteProbeSupport.stubProbe (fun _ -> Error(MarketDataCollectorUnavailable "collector exited with code 2"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithQuote probe)
+ "GET"
+ "/api/market/bond-quote?code=123121"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(503, status)
+ Assert.Contains("MARKET_DATA_UNAVAILABLE", body)
+ Assert.Contains("code 2", body)
+
+ [<Fact>]
+ member _.``market bond-quote endpoint rejects a malformed code with 400``() =
+ let probe =
+ BondQuoteProbeSupport.stubProbe (fun _ ->
+ Error(InvalidMarketDataRequest "bond code must contain exactly six digits"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithQuote probe)
+ "GET"
+ "/api/market/bond-quote?code=abc"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_MARKET_DATA_REQUEST", body)
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index 07db95f..be044a1 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -31,6 +31,7 @@
<Compile Include="RebalanceTests.fs" />
<Compile Include="NavDateProbeTests.fs" />
<Compile Include="NavSeriesProbeTests.fs" />
+ <Compile Include="BondQuoteProbeTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
index 6197950..1c57fd1 100644
--- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs
+++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
@@ -27,7 +27,20 @@ module NavDateProbeSupport =
let collectorReturning (fetch: unit -> Result<string, string>) =
{ new IMarketDataCollector with
member _.Search(_, _) = Ok ""
- member _.FetchNav(_, _) = fetch () }
+ member _.FetchNav(_, _) = fetch ()
+ member _.FetchBondQuote(_, _) = Ok "" }
+
+ let stubBondQuoteProbe =
+ { new IBondQuoteProbe with
+ member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "bond 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")
@@ -76,7 +89,9 @@ type NavDateProbeTests(fixture: PostgresFixture) =
member _.RecentNavSeries(_, _, _) = Ok [] }
let appWithProbe (probe: INavDateProbe) =
- App.createApplicationWithProbes (repository ()) { NavDates = probe; NavSeries = stubSeriesProbe }
+ App.createApplicationWithProbes
+ (repository ())
+ { NavDates = probe; NavSeries = stubSeriesProbe; BondQuotes = NavDateProbeSupport.stubBondQuoteProbe }
[<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
index 14fd8e8..f753cbe 100644
--- a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs
+++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs
@@ -36,7 +36,11 @@ type NavSeriesProbeTests(fixture: PostgresFixture) =
member _.RecentNavDates(_, _, _) = Ok [] }
let appWithSeries (probe: INavSeriesProbe) =
- App.createApplicationWithProbes (repository ()) { NavDates = stubDateProbe; NavSeries = probe }
+ App.createApplicationWithProbes
+ (repository ())
+ { NavDates = stubDateProbe
+ NavSeries = probe
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe }
[<Fact>]
member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() =