diff options
Diffstat (limited to 'tests/FundLab.Api.Tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | 6 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/NavDateProbeTests.fs | 12 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/NavSeriesProbeTests.fs | 3 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockQuoteProbeTests.fs | 188 |
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) |
