diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 01:12:52 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 01:12:52 +0800 |
| commit | 3cc0b4dcfeed042e5a69628c6b7f45580ad1e2f9 (patch) | |
| tree | b86805c2680327412d6f5d554d4506ebfe62fc2f /tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | |
| parent | 70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 (diff) | |
| download | fund-lab-3cc0b4dcfeed042e5a69628c6b7f45580ad1e2f9.tar.gz | |
Add real AKShare bond quote probe slice (3d-14)
Diffstat (limited to 'tests/FundLab.Api.Tests/BondQuoteProbeTests.fs')
| -rw-r--r-- | tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | 189 |
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) |
