From b6008d67232756d743699472865025159f6ec533 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 04:29:42 +0800 Subject: Add stock buy to holdings minimal vertical slice (3d-21) --- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Api.Tests/StockTradeTests.fs | 166 +++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 tests/FundLab.Api.Tests/StockTradeTests.fs (limited to 'tests/FundLab.Api.Tests') diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index 83c96bc..65352a7 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -34,6 +34,7 @@ + diff --git a/tests/FundLab.Api.Tests/StockTradeTests.fs b/tests/FundLab.Api.Tests/StockTradeTests.fs new file mode 100644 index 0000000..a2269a1 --- /dev/null +++ b/tests/FundLab.Api.Tests/StockTradeTests.fs @@ -0,0 +1,166 @@ +namespace FundLab.Api.Tests + +open System +open System.Threading +open Xunit +open FundLab.Api + +module StockTradeSupport = + let stubQuoteProbe (quote: StockQuote) = + { new IStockQuoteProbe with + member _.GetQuote(_, _) = Ok quote } + + let failingQuoteProbe (failure: MarketDataFailure) = + { new IStockQuoteProbe with + member _.GetQuote(_, _) = Error failure } + + let quote600519 = + { + Code = "600519" + Name = Some "贵州茅台" + Price = Some 1252.57m + Currency = "CNY" + } + + let appWithQuote (probe: IStockQuoteProbe) = + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = NavDateProbeSupport.stubBondQuoteProbe + StockQuotes = probe + StockDaily = NavDateProbeSupport.stubStockDailyProbe } + +[] +type StockTradeTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let createFund () = + let command = + { + Name = "股票闭环测试 FOF" + InitialCash = 100000.00m + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "stock-trade-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app probe = + let probes = StockTradeSupport.appWithQuote probe + App.createApplicationWithProbes (repository ()) probes + + let buy (probe: IStockQuoteProbe) fundId body idempotencyKey = + PersistenceTestHelpers.invoke + (app probe) + "POST" + (sprintf "/api/funds/%O/stock-trades" fundId) + [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] + body + + let readPositions (probe: IStockQuoteProbe) fundId = + PersistenceTestHelpers.invoke + (app probe) + "GET" + (sprintf "/api/funds/%O/stock-positions" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + [] + member _.``buying a stock with a live quote records a trade and a position``() = + let fundId = createFund () + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + + let status, body = + buy probe fundId """{"instrumentCode":"600519","quantity":"100"}""" (fixture.Key "stock-trade-buy-ok") + + Assert.Equal(201, status) + Assert.Contains("\"instrumentCode\":\"600519\"", body) + Assert.Contains("贵州茅台", body) + Assert.Contains("1252.57", body) + Assert.Contains("125257.00", body) + + let status, positionsBody = readPositions probe fundId + Assert.Equal(200, status) + Assert.Contains("\"instrumentCode\":\"600519\"", positionsBody) + Assert.Contains("\"quantity\":\"100.00000000\"", positionsBody) + Assert.Contains("125257.00", positionsBody) + + [] + member _.``buying an unknown code with an unavailable quote does not create a position``() = + let fundId = createFund () + + let probe = + StockTradeSupport.failingQuoteProbe ( + MarketDataCollectorUnavailable "AKShare collector failed: no valid quote for 999999" + ) + + let status, body = + buy probe fundId """{"instrumentCode":"999999","quantity":"100"}""" (fixture.Key "stock-trade-unknown") + + Assert.Equal(503, status) + Assert.Contains("MARKET_DATA_UNAVAILABLE", body) + + let status, positionsBody = readPositions probe fundId + Assert.Equal(200, status) + Assert.DoesNotContain("999999", positionsBody) + Assert.Contains("\"positions\":[]", positionsBody) + + [] + member _.``buying with a non-positive quantity is rejected without a position``() = + let fundId = createFund () + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + + let status, body = + buy probe fundId """{"instrumentCode":"600519","quantity":"0"}""" (fixture.Key "stock-trade-zero") + + Assert.Equal(400, status) + Assert.Contains("INVALID_STOCK_TRADE_REQUEST", body) + + let _, positionsBody = readPositions probe fundId + Assert.Contains("\"positions\":[]", positionsBody) + + [] + member _.``buying with a malformed code is rejected``() = + let fundId = createFund () + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + + let status, body = + buy probe fundId """{"instrumentCode":"abc","quantity":"100"}""" (fixture.Key "stock-trade-badcode") + + Assert.Equal(400, status) + Assert.Contains("six digits", body) + + [] + member _.``replaying a buy with the same idempotency key does not double the position``() = + let fundId = createFund () + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + let key = fixture.Key "stock-trade-replay" + + let status, _ = buy probe fundId """{"instrumentCode":"600519","quantity":"100"}""" key + Assert.Equal(201, status) + + let status, _ = buy probe fundId """{"instrumentCode":"600519","quantity":"100"}""" key + Assert.Equal(200, status) + + let _, positionsBody = readPositions probe fundId + Assert.Contains("\"quantity\":\"100.00000000\"", positionsBody) + Assert.DoesNotContain("\"quantity\":\"200.00000000\"", positionsBody) + + [] + member _.``buying a fund that does not exist answers 404``() = + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + + let status, body = + buy probe (Guid.NewGuid()) """{"instrumentCode":"600519","quantity":"100"}""" (fixture.Key "stock-trade-missing") + + Assert.Equal(404, status) + Assert.Contains("FUND_NOT_FOUND", body) -- cgit v1.2.3