summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests/OrderTests.fs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/FundLab.Api.Tests/OrderTests.fs')
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs333
1 files changed, 333 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)