diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:44:07 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:44:07 +0800 |
| commit | 205e2a592bf781f08fe23f3577cdfe74ff9a076a (patch) | |
| tree | 05aad6f87ca8f6c78fbc2d4c4d0d89356439936a /tests/FundLab.Api.Tests/OrderTests.fs | |
| parent | b8811922f4e167d50efb8817da35a12d8a9d653b (diff) | |
| download | fund-lab-205e2a592bf781f08fe23f3577cdfe74ff9a076a.tar.gz | |
Add additional capital deposit flow (3d-4)
Deposits credit a fund's available cash directly: Domain gains
CapitalPolicy (positive, two-decimal, overflow-checked amounts plus a
settle function that never touches units, NAV or realized P&L).
Persistence records deposits in fund_capital_deposits with a
fund_capital_idempotencies table, crediting cash and the deposit row
atomically under the same replay/conflict semantics as orders. The
API exposes POST /funds/{id}/capital/deposit and GET
/funds/{id}/capital/deposits behind the bearer token and idempotency
keys. The frontend adds an '追加资金' entry on the fund cash card with
in-place balance refresh, and repeats of the same amount reuse the
idempotency key so double submits credit cash exactly once. Browser
QA extends the J-series (J12-J18): invalid-amount rejection without
POST, single credited deposit with a 32-hex key, instant balance
refresh, unchanged units/cost proving no realized-income impact, and
same-key replay crediting only once.
Diffstat (limited to 'tests/FundLab.Api.Tests/OrderTests.fs')
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 130 |
1 files changed, 130 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs index 4fc1da0..1ab8fb8 100644 --- a/tests/FundLab.Api.Tests/OrderTests.fs +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -1880,3 +1880,133 @@ type RedemptionTests(fixture: PostgresFixture) = Assert.Contains("\"instrumentCode\":\"" + code + "\"", listResponse) Assert.Contains("\"status\":\"confirmed\"", listResponse) Assert.Contains("\"confirmedProceeds\":\"99.60\"", listResponse) + +[<Collection("postgres")>] +type CapitalDepositTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let createFund (initialCash: decimal) = + let command = + { + Name = "追加资金测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "capital-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 postDeposit (fundId: Guid) (idempotencyKey: string) (amount: string) (note: string) = + let body = sprintf "{\"amount\":\"%s\",\"note\":\"%s\"}" amount note + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/capital/deposit" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", idempotencyKey + ] + body + + let availableCash fundId = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- "SELECT available_cash FROM funds WHERE id = @fund_id" + let parameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid) + parameter.Value <- box fundId + command.ExecuteScalar() :?> decimal + + let depositCount fundId = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- "SELECT count(*) FROM fund_capital_deposits WHERE fund_id = @fund_id" + let parameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid) + parameter.Value <- box fundId + command.ExecuteScalar() :?> int64 + + [<Fact>] + member _.``capital deposit credits available cash and records the deposit``() = + let fundId = createFund 10000.00m + let key = fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N"))) + + let status, response = postDeposit fundId key "500.00" "首次追加" + + Assert.Equal(201, status) + Assert.Contains("\"amount\":\"500.00\"", response) + Assert.Equal(10500.00m, availableCash fundId) + Assert.Equal(1L, depositCount fundId) + + [<Fact>] + member _.``capital deposit replay with the same key credits cash exactly once``() = + let fundId = createFund 10000.00m + let key = fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N"))) + + let firstStatus, firstResponse = postDeposit fundId key "300.00" "重复追加" + let secondStatus, secondResponse = postDeposit fundId key "300.00" "重复追加" + + Assert.Equal(201, firstStatus) + Assert.Equal(200, secondStatus) + Assert.Equal(firstResponse, secondResponse) + Assert.Equal(10300.00m, availableCash fundId) + Assert.Equal(1L, depositCount fundId) + + let conflictingStatus, conflictingResponse = postDeposit fundId key "100.00" "不同金额" + + Assert.Equal(409, conflictingStatus) + Assert.Contains("IDEMPOTENCY_CONFLICT", conflictingResponse) + Assert.Equal(10300.00m, availableCash fundId) + + [<Fact>] + member _.``invalid deposit amounts are rejected without any cash movement``() = + let fundId = createFund 10000.00m + + for amount in [ "0.00"; "-5.00"; "1.234" ] do + let key = fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N"))) + let status, response = postDeposit fundId key amount "非法" + Assert.Equal(400, status) + Assert.Contains("INVALID_CAPITAL_REQUEST", response) + + Assert.Equal(10000.00m, availableCash fundId) + Assert.Equal(0L, depositCount fundId) + + [<Fact>] + member _.``deposit for an unknown fund returns 404``() = + let key = fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N"))) + let status, response = postDeposit (Guid.NewGuid()) key "100.00" "无基金" + + Assert.Equal(404, status) + Assert.Contains("FUND_NOT_FOUND", response) + + [<Fact>] + member _.``deposit history is exposed via GET``() = + let fundId = createFund 10000.00m + let _, _ = postDeposit fundId (fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N")))) "111.00" "第一笔" + let _, _ = postDeposit fundId (fixture.Key(sprintf "cap-%s" (Guid.NewGuid().ToString("N")))) "222.00" "第二笔" + + let status, response = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/capital/deposits" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"amount\":\"111.00\"", response) + Assert.Contains("\"amount\":\"222.00\"", response) + Assert.Contains("第一笔", response) |
