summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 04:29:42 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 04:29:42 +0800
commitb6008d67232756d743699472865025159f6ec533 (patch)
tree739cc45c5e9677a08c03cebd0d52a8b773984754 /tests/FundLab.Api.Tests
parentb5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b (diff)
downloadfund-lab-b6008d67232756d743699472865025159f6ec533.tar.gz
Add stock buy to holdings minimal vertical slice (3d-21)
Diffstat (limited to 'tests/FundLab.Api.Tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/StockTradeTests.fs166
2 files changed, 167 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)