summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 02:13:04 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 02:13:04 +0800
commit7da3c3d938e4b64a43963d602ab4a0a8c89f6f9e (patch)
tree6fc3c06f6474aa4ad06fde775b3618e8bf392fba /tests/FundLab.Api.Tests
parent0d1af831c10683bb1e5a5853e5924c5ddfb1310d (diff)
downloadfund-lab-7da3c3d938e4b64a43963d602ab4a0a8c89f6f9e.tar.gz
Add real AKShare stock quote probe slice (3d-17)
Diffstat (limited to 'tests/FundLab.Api.Tests')
-rw-r--r--tests/FundLab.Api.Tests/BondQuoteProbeTests.fs6
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/NavDateProbeTests.fs12
-rw-r--r--tests/FundLab.Api.Tests/NavSeriesProbeTests.fs3
-rw-r--r--tests/FundLab.Api.Tests/StockQuoteProbeTests.fs188
5 files changed, 205 insertions, 5 deletions
diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
index 442ed7c..c9821dc 100644
--- a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
+++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
@@ -23,7 +23,8 @@ module BondQuoteProbeSupport =
{ new IMarketDataCollector with
member _.Search(_, _) = Ok ""
member _.FetchNav(_, _) = Ok ""
- member _.FetchBondQuote(_, _) = fetch () }
+ member _.FetchBondQuote(_, _) = fetch ()
+ member _.FetchStockQuote(_, _) = Ok "" }
let stubProbe (getQuote: string * CancellationToken -> Result<BondQuote, MarketDataFailure>) =
{ new IBondQuoteProbe with
@@ -38,7 +39,8 @@ type BondQuoteProbeTests(fixture: PostgresFixture) =
(repository ())
{ NavDates = NavDateProbeSupport.stubNavDateProbe
NavSeries = NavDateProbeSupport.stubNavSeriesProbe
- BondQuotes = probe }
+ BondQuotes = probe
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe }
[<Fact>]
member _.``probe parses name, price and maturity date``() =
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index be044a1..755cc1d 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -32,6 +32,7 @@
<Compile Include="NavDateProbeTests.fs" />
<Compile Include="NavSeriesProbeTests.fs" />
<Compile Include="BondQuoteProbeTests.fs" />
+ <Compile Include="StockQuoteProbeTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
index 1c57fd1..cc513d7 100644
--- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs
+++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
@@ -28,12 +28,17 @@ module NavDateProbeSupport =
{ new IMarketDataCollector with
member _.Search(_, _) = Ok ""
member _.FetchNav(_, _) = fetch ()
- member _.FetchBondQuote(_, _) = Ok "" }
+ member _.FetchBondQuote(_, _) = Ok ""
+ member _.FetchStockQuote(_, _) = 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 stubNavDateProbe =
{ new INavDateProbe with
member _.RecentNavDates(_, _, _) = Ok [] }
@@ -91,7 +96,10 @@ type NavDateProbeTests(fixture: PostgresFixture) =
let appWithProbe (probe: INavDateProbe) =
App.createApplicationWithProbes
(repository ())
- { NavDates = probe; NavSeries = stubSeriesProbe; BondQuotes = NavDateProbeSupport.stubBondQuoteProbe }
+ { NavDates = probe
+ NavSeries = stubSeriesProbe
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe }
[<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 f753cbe..81d1733 100644
--- a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs
+++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs
@@ -40,7 +40,8 @@ type NavSeriesProbeTests(fixture: PostgresFixture) =
(repository ())
{ NavDates = stubDateProbe
NavSeries = probe
- BondQuotes = NavDateProbeSupport.stubBondQuoteProbe }
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe }
[<Fact>]
member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() =
diff --git a/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs
new file mode 100644
index 0000000..e29c666
--- /dev/null
+++ b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs
@@ -0,0 +1,188 @@
+namespace FundLab.Api.Tests
+
+open System.Threading
+open Xunit
+open FundLab.Api
+
+module StockQuoteProbeSupport =
+ let payload (code: string) (name: string option) (price: string option) =
+ let optionalText =
+ function
+ | Some value -> sprintf "\"%s\"" value
+ | None -> "null"
+
+ sprintf
+ "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"stock-quote\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"quote\":{\"name\":%s,\"price\":%s,\"currency\":\"CNY\"}}"
+ code
+ (optionalText name)
+ (optionalText price)
+
+ let collectorReturning (fetch: unit -> Result<string, string>) =
+ { new IMarketDataCollector with
+ member _.Search(_, _) = Ok ""
+ member _.FetchNav(_, _) = Ok ""
+ member _.FetchBondQuote(_, _) = Ok ""
+ member _.FetchStockQuote(_, _) = fetch () }
+
+ let stubProbe (getQuote: string * CancellationToken -> Result<StockQuote, MarketDataFailure>) =
+ { new IStockQuoteProbe with
+ member _.GetQuote(code, token) = getQuote (code, token) }
+
+[<Collection("postgres")>]
+type StockQuoteProbeTests(fixture: PostgresFixture) =
+ let repository () = FundRepository(fixture.ConnectionString)
+
+ let appWithQuote (probe: IStockQuoteProbe) =
+ App.createApplicationWithProbes
+ (repository ())
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = probe }
+
+ [<Fact>]
+ member _.``probe parses name, price and currency``() =
+ let json = StockQuoteProbeSupport.payload "600519" (Some "贵州茅台") (Some "1252.57")
+
+ let probe =
+ AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe
+
+ match probe.GetQuote("600519", CancellationToken.None) with
+ | Ok quote ->
+ Assert.Equal("600519", quote.Code)
+ Assert.Equal<string option>(Some "贵州茅台", quote.Name)
+ Assert.Equal<decimal option>(Some 1252.57m, quote.Price)
+ Assert.Equal("CNY", quote.Currency)
+ | Error failure -> failwithf "expected a stock quote, got %A" failure
+
+ [<Fact>]
+ member _.``probe keeps a missing name missing instead of fabricating data``() =
+ let json = StockQuoteProbeSupport.payload "600519" None (Some "1252.57")
+
+ let probe =
+ AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe
+
+ match probe.GetQuote("600519", CancellationToken.None) with
+ | Ok quote ->
+ Assert.Equal<string option>(None, quote.Name)
+ Assert.Equal<decimal option>(Some 1252.57m, quote.Price)
+ | Error failure -> failwithf "expected a stock quote, got %A" failure
+
+ [<Fact>]
+ member _.``probe rejects a malformed stock code without touching the collector``() =
+ let probe =
+ AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> failwith "collector should not be called"))
+ :> IStockQuoteProbe
+
+ 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 surfaces a no valid quote collector failure and does not cache it``() =
+ let mutable calls = 0
+
+ let collector =
+ StockQuoteProbeSupport.collectorReturning (fun () ->
+ calls <- calls + 1
+ Error "AKShare collector failed: no valid quote for 000000")
+
+ let probe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe
+
+ match probe.GetQuote("000000", CancellationToken.None) with
+ | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid quote for 000000", message)
+ | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other
+
+ match probe.GetQuote("000000", 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 =
+ AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IStockQuoteProbe
+
+ match probe.GetQuote("600519", CancellationToken.None) with
+ | Error(InvalidMarketDataPayload _) -> ()
+ | other -> failwithf "expected InvalidMarketDataPayload, got %A" other
+
+ [<Fact>]
+ member _.``probe memoises successful lookups per stock code``() =
+ let mutable calls = 0
+
+ let collector =
+ StockQuoteProbeSupport.collectorReturning (fun () ->
+ calls <- calls + 1
+ Ok(StockQuoteProbeSupport.payload "600519" (Some "贵州茅台") (Some "1252.57")))
+
+ let probe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe
+
+ let first = probe.GetQuote("600519", CancellationToken.None)
+ let second = probe.GetQuote("600519", CancellationToken.None)
+
+ Assert.Equal(1, calls)
+ Assert.Equal(first |> Result.map (fun quote -> quote.Price), second |> Result.map (fun quote -> quote.Price))
+
+ [<Fact>]
+ member _.``market stock-quote endpoint returns the probe record``() =
+ let probe =
+ StockQuoteProbeSupport.stubProbe (fun (code, _) ->
+ Assert.Equal("600519", code)
+
+ Ok
+ {
+ Code = "600519"
+ Name = Some "贵州茅台"
+ Price = Some 1252.57m
+ Currency = "CNY"
+ })
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithQuote probe)
+ "GET"
+ "/api/market/stock-quote?code=600519"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"code\":\"600519\"", body)
+ Assert.Contains("\"name\":\"贵州茅台\"", body)
+ Assert.Contains("1252.57", body)
+ Assert.Contains("\"currency\":\"CNY\"", body)
+
+ [<Fact>]
+ member _.``market stock-quote endpoint answers 503 when the probe fails``() =
+ let probe =
+ StockQuoteProbeSupport.stubProbe (fun _ -> Error(MarketDataCollectorUnavailable "collector exited with code 2"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithQuote probe)
+ "GET"
+ "/api/market/stock-quote?code=000000"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(503, status)
+ Assert.Contains("MARKET_DATA_UNAVAILABLE", body)
+ Assert.Contains("code 2", body)
+
+ [<Fact>]
+ member _.``market stock-quote endpoint rejects a malformed code with 400``() =
+ let probe =
+ StockQuoteProbeSupport.stubProbe (fun _ ->
+ Error(InvalidMarketDataRequest "stock code must contain exactly six digits"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithQuote probe)
+ "GET"
+ "/api/market/stock-quote?code=abc"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_MARKET_DATA_REQUEST", body)