summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/FundLab.Api.Tests/StockQuoteProbeTests.fs')
-rw-r--r--tests/FundLab.Api.Tests/StockQuoteProbeTests.fs188
1 files changed, 188 insertions, 0 deletions
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)