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