From 205e2a592bf781f08fe23f3577cdfe74ff9a076a Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 11:44:07 +0800 Subject: Add additional capital deposit flow (3d-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- qa/driver/browser-test.js | 63 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) (limited to 'qa') diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 677ae54..acc576d 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -13,6 +13,8 @@ const confirmPosts = []; const confirmPostStatuses = []; const redemptionPosts = []; const redemptionConfirmResponses = []; +const capitalPosts = []; +const capitalPostStatuses = []; let positionGets = 0; let lastPositionsResponse = null; let fundPostResponses = 0; @@ -109,6 +111,7 @@ async function summaryLabelExists(page, label, name) { } catch {} } if (r.url().includes("/orders") && r.request().method() === "POST") orderPostStatuses.push(r.status()); + if (r.url().includes("/capital") && r.request().method() === "POST") capitalPostStatuses.push(r.status()); if (r.url().includes("/confirm") && r.url().includes("/redemptions") && r.request().method() === "POST") { try { lastRedemptionConfirmResponse = await r.json(); @@ -126,7 +129,7 @@ async function summaryLabelExists(page, label, name) { lastPositionsResponse = await r.json(); } catch {} } - if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url())) { + if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); @@ -143,6 +146,9 @@ async function summaryLabelExists(page, label, name) { if (r.url().includes("/redemptions") && r.method() === "POST" && !r.url().includes("/confirm")) { redemptionPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } + if (r.url().includes("/capital") && r.method() === "POST") { + capitalPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); + } }); page.on("console", (m) => { if (/^DBG /.test(m.text())) console.log("[dbg] " + m.text()); @@ -167,6 +173,7 @@ async function summaryLabelExists(page, label, name) { await orderScenario(page); await confirmScenario(page); await redeemScenario(page); + await capitalScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -280,6 +287,60 @@ async function redeemScenario(page) { await page.screenshot({ path: SHOTS + "/13-redemption-settled.png" }); } + +async function capitalScenario(page) { + // J12-J17: 追加资金(接 K 场景:现金 4937.99,持仓 50.00000000/成本 59.51 不变) + const initialCash = "4937.99"; + await page.waitForSelector(".capital-deposit-action", { timeout: 10000 }); + + await page.fill(".capital-amount-input", "0.00"); + await page.click(".capital-deposit-action"); + await expectText(page, ".error-banner", "追加金额必须是大于零的两位小数金额,例如 1000.00", "J12 非法金额(0.00)拒绝"); + await page.fill(".capital-amount-input", "abc"); + await page.click(".capital-deposit-action"); + await expectText(page, ".error-banner", "追加金额必须是大于零的两位小数金额,例如 1000.00", "J13 非法金额(abc)拒绝"); + check("J14 校验阶段无追加 POST", capitalPosts.length === 0, `posts=${capitalPosts.length}`); + + await page.fill(".capital-amount-input", "500.00"); + await page.click(".capital-deposit-action"); + for (let i = 0; i < 100 && capitalPostStatuses.length === 0; i++) { + await page.waitForTimeout(100); + } + check("J15 追加 POST 恰好 1 次且幂等键 32 位十六进制", capitalPosts.length === 1 && /^[0-9a-f]{32}$/.test(capitalPosts[0].key), JSON.stringify(capitalPosts)); + await page.waitForFunction( + (want) => { + const rows = Array.from(document.querySelectorAll(".fund-detail-row")); + return rows.some((r) => (r.querySelector(".fund-detail-label")?.textContent || "").trim() === "可用现金" && (r.querySelector(".fund-detail-value")?.textContent || "").trim() === want); + }, + "5437.99", + { timeout: 15000 } + ); + check("J16 追加后现金即时刷新 5437.99", true); + + // 现金守恒:持仓份额与成本不变,现金差等于追加额 + const summary = ((await page.textContent(".fund-summary")) || ""); + const holdings = ((await page.textContent(".positions-panel")) || ""); + check( + "J17 追加不计入已实现收益(份额与成本不变)", + holdings.includes("份额 50.00000000") && holdings.includes("成本 59.51") && summary.includes("5437.99"), + holdings.slice(0, 200) + ); + + // 幂等 ×1:同金额重试复用键 → replay 200,现金只入账一次 + await page.click(".capital-deposit-action"); + for (let i = 0; i < 100 && capitalPostStatuses.length < 2; i++) { + await page.waitForTimeout(100); + } + await page.waitForTimeout(500); + const summaryAfterRetry = ((await page.textContent(".fund-summary")) || ""); + check( + "J18 同键重放只入账一次", + capitalPostStatuses.length === 2 && capitalPosts[0].key === capitalPosts[1].key && summaryAfterRetry.includes("5437.99"), + JSON.stringify({ statuses: capitalPostStatuses, keys: capitalPosts.map((p) => p.key), summary: summaryAfterRetry.slice(0, 160) }) + ); + await page.screenshot({ path: SHOTS + "/14-capital-deposit.png" }); +} + const failed = results.filter((r) => !r.ok); console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`); process.exit(failed.length > 0 ? 1 : 0); -- cgit v1.2.3