diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockCashflowTests.fs | 147 |
2 files changed, 148 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index f7e1225..1c2dd86 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -36,6 +36,7 @@ <Compile Include="StockDailyProbeTests.fs" /> <Compile Include="StockTradeTests.fs" /> <Compile Include="StockSellTests.fs" /> + <Compile Include="StockCashflowTests.fs" /> <Compile Include="BondTradeTests.fs" /> <Compile Include="FundValuationTests.fs" /> <Compile Include="SipPlanStatusTests.fs" /> 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) |
