summaryrefslogtreecommitdiff
path: root/qa/driver
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 11:44:07 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 11:44:07 +0800
commit205e2a592bf781f08fe23f3577cdfe74ff9a076a (patch)
tree05aad6f87ca8f6c78fbc2d4c4d0d89356439936a /qa/driver
parentb8811922f4e167d50efb8817da35a12d8a9d653b (diff)
downloadfund-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 'qa/driver')
-rw-r--r--qa/driver/browser-test.js63
1 files changed, 62 insertions, 1 deletions
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);