diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:29:42 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:29:42 +0800 |
| commit | b6008d67232756d743699472865025159f6ec533 (patch) | |
| tree | 739cc45c5e9677a08c03cebd0d52a8b773984754 /tests | |
| parent | b5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b (diff) | |
| download | fund-lab-b6008d67232756d743699472865025159f6ec533.tar.gz | |
Add stock buy to holdings minimal vertical slice (3d-21)
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockTradeTests.fs | 166 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 74 |
3 files changed, 241 insertions, 0 deletions
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 @@ <Compile Include="BondQuoteProbeTests.fs" /> <Compile Include="StockQuoteProbeTests.fs" /> <Compile Include="StockDailyProbeTests.fs" /> + <Compile Include="StockTradeTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> 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 } + +[<Collection("postgres")>] +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" ] + "" + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 2892633..25d7867 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -584,6 +584,80 @@ module ConfirmationBoundaryTests = Assert.False(updated.stockDailyInFlight) [<Fact>] + let ``stock buy requires a created fund before dispatching a trade`` () = + let model = { init () with token = "qa-token"; stockCode = "600519"; stockTradeQuantity = "100" } + let updated, _ = update StockTradeRequested model + + Assert.Contains("请先创建一个基金", updated.stockTradeMessage |> Option.defaultValue "") + Assert.False(updated.stockTradeInFlight) + + [<Fact>] + let ``stock buy rejects a non-positive quantity without dispatching`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(FundBoundaryTests.sampleFund "3fa85f64-5717-4562-b3fc-2c963f66afa6") + stockCode = "600519" + stockTradeQuantity = "0" } + + let updated, _ = update StockTradeRequested model + + Assert.Contains("数量必须是大于零的整数股数", updated.stockTradeMessage |> Option.defaultValue "") + Assert.False(updated.stockTradeInFlight) + + [<Fact>] + let ``stock buy failure keeps no position and surfaces a message`` () = + let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + let model = + { init () with + token = "qa-token" + createdFund = Some(FundBoundaryTests.sampleFund fundId) + stockCode = "999999" + stockTradeQuantity = "100" + stockTradeInFlight = true + stockTradeSeq = 1 } + + let updated, _ = update (StockTradeFailed(1, fundId, "collector exited with code 2")) model + + Assert.Empty updated.stockPositions + Assert.Contains("买入失败", updated.stockTradeMessage |> Option.defaultValue "") + Assert.False(updated.stockTradeInFlight) + + [<Fact>] + let ``stock positions read completion stores decoded rows`` () = + let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + let model = + { init () with + createdFund = Some(FundBoundaryTests.sampleFund fundId) + stockPositionsReadSeq = 1 + stockPositionsInFlight = true } + + let raw : RawStockPositions = + { + fundId = fundId + positions = + [| + { + instrumentCode = "600519" + stockName = box "贵州茅台" + quantity = "100" + costCash = "125257.00" + lastTradedAt = "2026-09-21T08:00:00Z" + } + |] + } + + let updated, _ = update (StockPositionsReadCompleted(1, fundId, raw)) model + + Assert.Equal(1, updated.stockPositions.Length) + Assert.Equal("600519", updated.stockPositions[0].instrumentCode) + Assert.Equal(Some "贵州茅台", updated.stockPositions[0].stockName) + Assert.Equal("100", updated.stockPositions[0].quantity) + Assert.False(updated.stockPositionsInFlight) + + [<Fact>] let ``order decode keeps decimal strings and decodes option fields`` () = let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) let pending = Api.decodeOrder (samplePendingRaw ()) |
