diff options
Diffstat (limited to 'tests/FundLab.Api.Tests/OrderTests.fs')
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 333 |
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) |
