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) = { 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) = { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe BondQuotes = NavDateProbeSupport.stubBondQuoteProbe StockQuotes = stubQuoteProbe quotes StockDaily = NavDateProbeSupport.stubStockDailyProbe } [] 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) = App.createApplicationWithProbes (repository ()) (StockCashflowSupport.probes quotes) let buy (quotes: Map) 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) 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) 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) fundId = PersistenceTestHelpers.invoke (app quotes) "GET" (sprintf "/api/funds/%O/stock-cashflows" fundId) [ "Authorization", "Bearer test-token" ] "" [] 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) [] 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) [] 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) [] 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)