summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests/StockCashflowTests.fs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/FundLab.Api.Tests/StockCashflowTests.fs')
-rw-r--r--tests/FundLab.Api.Tests/StockCashflowTests.fs147
1 files changed, 147 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/StockCashflowTests.fs b/tests/FundLab.Api.Tests/StockCashflowTests.fs
new file mode 100644
index 0000000..0468edc
--- /dev/null
+++ b/tests/FundLab.Api.Tests/StockCashflowTests.fs
@@ -0,0 +1,147 @@
+namespace FundLab.Api.Tests
+
+open System
+open Xunit
+open FundLab.Api
+
+module StockCashflowSupport =
+ let quote (code: string) (price: decimal) : StockQuote =
+ { Code = code; Name = Some "贵州茅台"; Price = Some price; Currency = "CNY"; Market = Some "sh"; Adjust = Some "none"; Suspended = Some false }
+
+ let stubQuoteProbe (quotes: Map<string, decimal>) =
+ { new IStockQuoteProbe with
+ member _.GetQuote(code, _) =
+ match quotes.TryFind code with
+ | Some price -> Ok(quote code price)
+ | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) }
+
+ let probes (quotes: Map<string, decimal>) =
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = stubQuoteProbe quotes
+ StockDaily = NavDateProbeSupport.stubStockDailyProbe }
+
+[<Collection("postgres")>]
+type StockCashflowTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let createFund () =
+ let command =
+ {
+ Name = "股票现金账本测试 FOF"
+ InitialCash = 1000000.00m
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "stock-cashflow-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 (quotes: Map<string, decimal>) =
+ App.createApplicationWithProbes (repository ()) (StockCashflowSupport.probes quotes)
+
+ let buy (quotes: Map<string, decimal>) fundId body idempotencyKey =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "POST"
+ (sprintf "/api/funds/%O/stock-trades" fundId)
+ [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
+ body
+
+ let sell (quotes: Map<string, decimal>) fundId body idempotencyKey =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "POST"
+ (sprintf "/api/funds/%O/stock-sells" fundId)
+ [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
+ body
+
+ let recordCashflow (quotes: Map<string, decimal>) fundId body idempotencyKey =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "POST"
+ (sprintf "/api/funds/%O/stock-cashflows" fundId)
+ [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
+ body
+
+ let readCashflows (quotes: Map<string, decimal>) fundId =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "GET"
+ (sprintf "/api/funds/%O/stock-cashflows" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``buying a stock records a buy cashflow event``() =
+ let fundId = createFund ()
+ let quotes = Map.ofList [ "600519", 1252.57m ]
+
+ let buyStatus, _ = buy quotes fundId """{"instrumentCode":"600519","quantity":"100"}""" (fixture.Key "stock-cashflow-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body = readCashflows quotes fundId
+ Assert.Equal(200, status)
+ Assert.Contains("\"eventType\":\"buy\"", body)
+ Assert.Contains("\"instrumentCode\":\"600519\"", body)
+ Assert.Contains("\"amount\":\"125257.00\"", body)
+
+ [<Fact>]
+ member _.``selling a stock records a sell cashflow event``() =
+ let fundId = createFund ()
+ let quotes = Map.ofList [ "600519", 1252.57m ]
+
+ let buyStatus, _ = buy quotes fundId """{"instrumentCode":"600519","quantity":"100"}""" (fixture.Key "stock-cashflow-sell-buy")
+ Assert.Equal(201, buyStatus)
+
+ let sellStatus, _ = sell quotes fundId """{"instrumentCode":"600519","quantity":"40"}""" (fixture.Key "stock-cashflow-sell")
+ Assert.Equal(201, sellStatus)
+
+ let _, body = readCashflows quotes fundId
+ Assert.Contains("\"eventType\":\"sell\"", body)
+ Assert.Contains("\"amount\":\"50102.80\"", body)
+
+ [<Fact>]
+ member _.``a recorded dividend cashflow is idempotent``() =
+ let fundId = createFund ()
+ let quotes = Map.ofList [ "600519", 1252.57m ]
+ let key = fixture.Key "stock-cashflow-dividend"
+
+ let body =
+ """{"instrumentCode":"600519","eventType":"dividend","eventDate":"2026-06-20","quantity":"100","amount":"250.00"}"""
+
+ let firstStatus, firstBody = recordCashflow quotes fundId body key
+ Assert.Equal(201, firstStatus)
+ Assert.Contains("\"eventType\":\"dividend\"", firstBody)
+ Assert.Contains("\"amount\":\"250.00\"", firstBody)
+
+ let secondStatus, _ = recordCashflow quotes fundId body key
+ Assert.Equal(200, secondStatus)
+
+ let _, events = readCashflows quotes fundId
+ Assert.Contains("\"eventType\":\"dividend\"", events)
+
+ [<Fact>]
+ member _.``a malformed event type is rejected``() =
+ let fundId = createFund ()
+ let quotes = Map.ofList [ "600519", 1252.57m ]
+
+ let status, body =
+ recordCashflow
+ quotes
+ fundId
+ """{"instrumentCode":"600519","eventType":"interest","eventDate":"2026-06-20","amount":"1.00"}"""
+ (fixture.Key "stock-cashflow-bad-type")
+
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_STOCK_CASHFLOW_REQUEST", body)