summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs333
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs48
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs76
3 files changed, 457 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs
index d9725d5..4fc1da0 100644
--- a/tests/FundLab.Api.Tests/OrderTests.fs
+++ b/tests/FundLab.Api.Tests/OrderTests.fs
@@ -1547,3 +1547,336 @@ type SubscriptionConfirmationTests(fixture: PostgresFixture) =
Assert.Equal(200, cashStatus)
Assert.Contains("\"availableCash\":\"9000.00\"", cashBody)
+
+[<Collection("postgres")>]
+type RedemptionTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let seedInstrument () =
+ let code = Random.Shared.Next(0, 1000000).ToString("D6")
+ let payload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero)
+ Instruments = [ { Code = code; Name = "赎回测试基金"; FundType = None } ]
+ }
+
+ repository().UpsertInstruments(payload, "redemption-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "赎回测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "redemption-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 () = App.createApplication (repository ())
+
+ let truncateMicroseconds (moment: DateTimeOffset) =
+ let utc = moment.ToUniversalTime()
+ DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero)
+
+ let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) (collectedAt: DateTimeOffset) =
+ let revision = sprintf "akshare-test/%O" (Guid.NewGuid())
+ let payload: MarketDataNavPayload =
+ {
+ Source = "akshare"
+ SourceRevision = revision
+ CollectedAt = collectedAt
+ Code = code
+ Observations =
+ [
+ {
+ NavDate = navDate
+ PublishedAt = None
+ Nav = nav
+ AccumulatedNav = Some nav
+ DailyReturn = Some 0.0m
+ }
+ ]
+ }
+
+ repository().UpsertNavObservations(payload, sprintf "redemption-quote-hash/%s" revision)
+
+ let insertQuote (code: string) (nav: decimal) (collectedAt: DateTimeOffset) =
+ insertQuoteOnDate code nav (ConfirmationPolicy.tradeDateFor DateTimeOffset.Now) collectedAt
+
+ let execute (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- sql
+
+ for name, value, dbType in parameters do
+ let parameter = command.Parameters.Add(name, dbType)
+ parameter.Value <- value
+
+ command.ExecuteNonQuery() |> ignore
+
+ let scalarDecimal (sql: string) (parameters: (string * obj * NpgsqlTypes.NpgsqlDbType) list) =
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- sql
+
+ for name, value, dbType in parameters do
+ let parameter = command.Parameters.Add(name, dbType)
+ parameter.Value <- value
+
+ command.ExecuteScalar() :?> decimal
+
+ let availableCash fundId =
+ scalarDecimal "SELECT available_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+
+ let reservedCash fundId =
+ scalarDecimal "SELECT reserved_cash FROM funds WHERE id = @fund_id" [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ]
+
+ let positionValue fundId code column =
+ scalarDecimal
+ (sprintf "SELECT %s FROM fund_positions WHERE fund_id = @fund_id AND instrument_code = @code" column)
+ [
+ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid
+ "code", box code, NpgsqlTypes.NpgsqlDbType.Text
+ ]
+
+ let postOrder fundId idempotencyKey amount fee =
+ let code = seedInstrument ()
+ let body = sprintf "{\"fundCode\":\"%s\",\"amount\":\"%s\",\"feeAmount\":\"%s\"}" code amount fee
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/orders" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", idempotencyKey
+ ]
+ body
+ |> fun (status, response) ->
+ if status <> 201 then failwithf "unexpected order status %d: %s" status response
+ code, PersistenceTestHelpers.responseId response
+
+ let confirmOrder (fundId: Guid) (orderId: Guid) (code: string) =
+ let collected = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ insertQuote code 2.5m collected
+ let key = fixture.Key(sprintf "confirm-%s" (Guid.NewGuid().ToString("N")))
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", key
+ ]
+ ""
+ |> fun (status, response) ->
+ if status <> 200 then failwithf "unexpected confirm status %d: %s" status response
+
+ let postRedemption (fundId: Guid) (code: string) (idempotencyKey: string) (units: string) (fee: string) =
+ let body =
+ sprintf
+ "{\"instrumentCode\":\"%s\",\"units\":\"%s\",\"feeAmount\":\"%s\"}"
+ code
+ units
+ fee
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/redemptions" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", idempotencyKey
+ ]
+ body
+
+ let confirmRedemptionOverHttp (fundId: Guid) (orderId: Guid) (idempotencyKey: string) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/redemptions/%O/confirm" fundId orderId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", idempotencyKey
+ ]
+ ""
+
+ let getRedemptionsOverHttp (fundId: Guid) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/redemptions" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let confirmedSubscription fundId initialCash amount =
+ let code, orderId = postOrder fundId (fixture.Key(sprintf "sub-%s" (Guid.NewGuid().ToString("N")))) amount "0.00"
+ confirmOrder fundId orderId code
+ code
+
+ [<Fact>]
+ member _.``redemption submission freezes holding units without touching cash``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let status, response = postRedemption fundId code key "150.00000000" "1.50"
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"status\":\"submitted\"", response)
+ Assert.Contains("\"units\":\"150.00000000\"", response)
+ Assert.Contains("\"feeAmount\":\"1.50\"", response)
+
+ Assert.Equal(9000.00m, availableCash fundId)
+ Assert.Equal(0.00m, reservedCash fundId)
+ Assert.Equal(400.00000000m, positionValue fundId code "units")
+ Assert.Equal(150.00000000m, positionValue fundId code "reserved_units")
+ Assert.Equal(1000.00m, positionValue fundId code "cost_cash")
+
+ [<Fact>]
+ member _.``redemption above available units is rejected without freezing``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let status, response = postRedemption fundId code key "400.00000001" "0.00"
+
+ Assert.Equal(409, status)
+ Assert.Contains("INSUFFICIENT_UNITS", response)
+ Assert.Equal(0.00000000m, positionValue fundId code "reserved_units")
+ Assert.Equal(400.00000000m, positionValue fundId code "units")
+
+ [<Fact>]
+ member _.``redemption replay returns the same order without double freezing``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let firstStatus, firstResponse = postRedemption fundId code key "100.00000000" "0.50"
+ let secondStatus, secondResponse = postRedemption fundId code key "100.00000000" "0.50"
+
+ Assert.Equal(201, firstStatus)
+ Assert.Equal(200, secondStatus)
+ Assert.Equal(firstResponse, secondResponse)
+ Assert.Equal(100.00000000m, positionValue fundId code "reserved_units")
+
+ let conflictingStatus, conflictingResponse = postRedemption fundId code key "50.00000000" "0.50"
+
+ Assert.Equal(409, conflictingStatus)
+ Assert.Contains("IDEMPOTENCY_CONFLICT", conflictingResponse)
+ Assert.Equal(100.00000000m, positionValue fundId code "reserved_units")
+
+ [<Fact>]
+ member _.``redemption confirmation settles cash and writes off position``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let _, submitResponse = postRedemption fundId code key "150.00000000" "1.50"
+ use document = System.Text.Json.JsonDocument.Parse(submitResponse)
+ let orderId = document.RootElement.GetProperty("id").GetGuid()
+
+ let status, response = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N"))))
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"status\":\"confirmed\"", response)
+ Assert.Contains("\"confirmedNav\":\"2.50000000\"", response)
+ Assert.Contains("\"confirmedProceeds\":\"373.50\"", response)
+ Assert.Contains("\"confirmedCostReleased\":\"375.00\"", response)
+
+ Assert.Equal(9373.50m, availableCash fundId)
+ Assert.Equal(0.00m, reservedCash fundId)
+ Assert.Equal(250.00000000m, positionValue fundId code "units")
+ Assert.Equal(0.00000000m, positionValue fundId code "reserved_units")
+ Assert.Equal(625.00m, positionValue fundId code "cost_cash")
+
+ [<Fact>]
+ member _.``full redemption removes the position row while cash conservation holds``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let key = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let _, submitResponse = postRedemption fundId code key "400.00000000" "0.00"
+ use document = System.Text.Json.JsonDocument.Parse(submitResponse)
+ let orderId = document.RootElement.GetProperty("id").GetGuid()
+
+ let status, response = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N"))))
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"confirmedProceeds\":\"1000.00\"", response)
+ Assert.Contains("\"confirmedCostReleased\":\"1000.00\"", response)
+
+ use connection = new NpgsqlConnection(fixture.ConnectionString)
+ connection.Open()
+ use command = connection.CreateCommand()
+ command.CommandText <- "SELECT count(*) FROM fund_positions WHERE fund_id = @fund_id AND instrument_code = @code"
+ let fundParameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid)
+ fundParameter.Value <- box fundId
+ let codeParameter = command.Parameters.Add("code", NpgsqlTypes.NpgsqlDbType.Text)
+ codeParameter.Value <- box code
+ let remaining = command.ExecuteScalar() :?> int64
+
+ Assert.Equal(0L, remaining)
+ Assert.Equal(10000.00m, availableCash fundId)
+ Assert.Equal(0.00m, reservedCash fundId)
+
+ [<Fact>]
+ member _.``redemption confirm replay keeps cash and positions stable``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let submitKey = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let _, submitResponse = postRedemption fundId code submitKey "80.00000000" "0.80"
+ use document = System.Text.Json.JsonDocument.Parse(submitResponse)
+ let orderId = document.RootElement.GetProperty("id").GetGuid()
+
+ let confirmKey = fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N")))
+ let firstStatus, firstResponse = confirmRedemptionOverHttp fundId orderId confirmKey
+ let secondStatus, secondResponse = confirmRedemptionOverHttp fundId orderId confirmKey
+
+ Assert.Equal(200, firstStatus)
+ Assert.Equal(200, secondStatus)
+ Assert.Equal(firstResponse, secondResponse)
+ Assert.Equal(9000.00m + 199.20m, availableCash fundId)
+ Assert.Equal(320.00000000m, positionValue fundId code "units")
+
+ let conflictingStatus, conflictingResponse = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N"))))
+
+ Assert.Equal(409, conflictingStatus)
+ Assert.Contains("ORDER_ALREADY_CONFIRMED", conflictingResponse)
+ Assert.Equal(9000.00m + 199.20m, availableCash fundId)
+
+ [<Fact>]
+ member _.``redemption list exposes confirmation evidence``() =
+ let fundId = createFund 10000.00m
+ let code = confirmedSubscription fundId 10000.00m "1000.00"
+ let submitKey = fixture.Key(sprintf "red-%s" (Guid.NewGuid().ToString("N")))
+
+ let _, submitResponse = postRedemption fundId code submitKey "40.00000000" "0.40"
+ use document = System.Text.Json.JsonDocument.Parse(submitResponse)
+ let orderId = document.RootElement.GetProperty("id").GetGuid()
+
+ let _, confirmResponse = confirmRedemptionOverHttp fundId orderId (fixture.Key(sprintf "redconf-%s" (Guid.NewGuid().ToString("N"))))
+ let listStatus, listResponse = getRedemptionsOverHttp fundId
+
+ Assert.Equal(200, listStatus)
+ Assert.Contains("\"instrumentCode\":\"" + code + "\"", listResponse)
+ Assert.Contains("\"status\":\"confirmed\"", listResponse)
+ Assert.Contains("\"confirmedProceeds\":\"99.60\"", listResponse)
diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs
index f2b4138..6a9fc66 100644
--- a/tests/FundLab.Domain.Tests/DomainTests.fs
+++ b/tests/FundLab.Domain.Tests/DomainTests.fs
@@ -491,3 +491,51 @@ module DomainTests =
Assert.Equal(Error(InvalidObservation "net assets cannot be negative"), negative)
Assert.Equal(Error(InvalidObservation "first net assets must be positive"), zero)
+
+module RedemptionPolicyTests =
+
+ open Xunit
+ open FundLab.Domain
+
+ let private unwrap result =
+ match result with
+ | Ok value -> value
+ | Error error -> failwithf "%A" error
+
+ [<Fact>]
+ let ``redemption request above available units is rejected`` () =
+ Assert.Equal(Error "requested units exceed available holdings", RedemptionPolicy.validateRequest 10.00000000m 10.00000001m 0.00m)
+ Assert.Equal(Error "redemption units must be positive", RedemptionPolicy.validateRequest 10.00000000m 0m 0.00m)
+ Assert.Equal(Error "redemption fee cannot be negative", RedemptionPolicy.validateRequest 10.00000000m 5.00000000m -0.01m)
+ Assert.Equal(Ok(), RedemptionPolicy.validateRequest 10.00000000m 10.00000000m 1.50m)
+
+ [<Fact>]
+ let ``full redemption releases the entire position cost`` () =
+ let computed = RedemptionPolicy.compute 40.00000000m 2.5m 1.00m 100.00m 40.00000000m |> unwrap
+
+ Assert.Equal(100.00m, computed.GrossCash)
+ Assert.Equal(1.00m, computed.Fee)
+ Assert.Equal(99.00m, computed.Proceeds)
+ Assert.Equal(100.00m, computed.RedeemedCost)
+
+ [<Fact>]
+ let ``partial redemption writes off cost pro rata rounded down`` () =
+ let computed = RedemptionPolicy.compute 10.00000000m 2.5m 0.00m 100.00m 40.00000000m |> unwrap
+
+ Assert.Equal(25.00m, computed.GrossCash)
+ Assert.Equal(0.00m, computed.Fee)
+ Assert.Equal(25.00m, computed.Proceeds)
+ Assert.Equal(25.00m, computed.RedeemedCost)
+
+ let uneven = RedemptionPolicy.compute 1.00000000m 3m 0.00m 100.00m 3.00000000m |> unwrap
+
+ Assert.Equal(3.00m, uneven.GrossCash)
+ Assert.Equal(33.33m, uneven.RedeemedCost)
+
+ [<Fact>]
+ let ``redemption computation rejects invalid economics`` () =
+ Assert.Equal(Error "redemption units exceed position units", RedemptionPolicy.compute 41.00000000m 2.5m 0.00m 100.00m 40.00000000m)
+ Assert.Equal(Error "unit nav must be positive", RedemptionPolicy.compute 1.00000000m 0m 0.00m 100.00m 40.00000000m)
+ Assert.Equal(Error "redemption fee exceeds redemption value", RedemptionPolicy.compute 1.00000000m 2.5m 3.00m 100.00m 40.00000000m)
+ Assert.Equal(Error "redemption proceeds rounds to zero", RedemptionPolicy.compute 0.00000001m 1500000m 0.01m 100.00m 40.00000000m)
+ Assert.Equal(Error "redemption cash rounds to zero", RedemptionPolicy.compute 0.00000001m 2m 0.00m 100.00m 40.00000000m)
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index d48d08b..015ed45 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -379,6 +379,7 @@ module ConfirmationBoundaryTests =
[|
{ instrumentCode = "000001"
units = "40.00000000"
+ reservedUnits = "0.00000000"
costCash = "100.00"
lastConfirmedAt = "2026-09-21T11:00:00+00:00"
valuationNav = someText "2.61000000"
@@ -386,6 +387,7 @@ module ConfirmationBoundaryTests =
valuationCollectedAt = someText "2026-09-21T09:30:00+00:00" }
{ instrumentCode = "110011"
units = "10.00000000"
+ reservedUnits = "5.00000000"
costCash = "25.00"
lastConfirmedAt = "2026-09-21T11:00:00+00:00"
valuationNav = null
@@ -559,3 +561,77 @@ module ConfirmationBoundaryTests =
let stalePositions, _ = update (PositionsReadCompleted(3, samplePositions ())) cleared
Assert.Equal(None, stalePositions.positions)
+
+module RedemptionBoundaryTests =
+
+ let sampleRedemptionRaw (status: string) : RawRedemption =
+ {
+ id = "9b2f1c3d-0000-0000-0000-000000000001"
+ instrumentCode = "000001"
+ units = "150.00000000"
+ feeAmount = "1.50"
+ status = status
+ submittedAt = "2026-09-21T10:00:00+00:00"
+ tradeDate = "2026-09-21"
+ pendingReason = null
+ confirmedAt = null
+ confirmedNav = null
+ confirmedNavDate = null
+ confirmedProceeds = null
+ confirmedCostReleased = null
+ }
+
+ let confirmedRedemptionRaw () : RawRedemption =
+ let someText (value: string) : obj = box { case = "Some"; fields = [| value |] }
+
+ { sampleRedemptionRaw "confirmed" with
+ pendingReason = null
+ confirmedAt = someText "2026-09-21T11:00:00+00:00"
+ confirmedNav = someText "2.50000000"
+ confirmedNavDate = someText "2026-09-21"
+ confirmedProceeds = someText "373.50"
+ confirmedCostReleased = someText "375.00" }
+
+ [<Fact>]
+ let ``redemption decode keeps decimal strings and option fields`` () =
+ let confirmed = Api.decodeRedemption (confirmedRedemptionRaw ())
+
+ Assert.Equal("confirmed", confirmed.status)
+ Assert.Equal("150.00000000", confirmed.units)
+ Assert.Equal("1.50", confirmed.feeAmount)
+ Assert.Equal("2.50000000", confirmed.confirmedNav.Value)
+ Assert.Equal("373.50", confirmed.confirmedProceeds.Value)
+ Assert.Equal("375.00", confirmed.confirmedCostReleased.Value)
+ Assert.Equal(None, confirmed.pendingReason)
+
+ let sampleRedemptionPositions () : RawPositions =
+ {
+ availableCash = "9900.00"
+ reservedCash = "101.50"
+ positions =
+ [|
+ { instrumentCode = "000001"
+ units = "40.00000000"
+ reservedUnits = "0.00000000"
+ costCash = "100.00"
+ lastConfirmedAt = "2026-09-21T11:00:00+00:00"
+ valuationNav = null
+ valuationNavDate = null
+ valuationCollectedAt = null }
+ { instrumentCode = "110011"
+ units = "10.00000000"
+ reservedUnits = "5.00000000"
+ costCash = "25.00"
+ lastConfirmedAt = "2026-09-21T11:00:00+00:00"
+ valuationNav = null
+ valuationNavDate = null
+ valuationCollectedAt = null }
+ |]
+ }
+
+ [<Fact>]
+ let ``positions decode keeps reserved units for frozen shares`` () =
+ let decoded = Api.decodePositions (sampleRedemptionPositions ())
+
+ Assert.Equal("0.00000000", decoded.positions[0].reservedUnits)
+ Assert.Equal("5.00000000", decoded.positions[1].reservedUnits)