diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:21:51 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 11:21:51 +0800 |
| commit | b8811922f4e167d50efb8817da35a12d8a9d653b (patch) | |
| tree | b85cc56677be5d4d58ad2277f00cbc935933301e /qa/driver | |
| parent | 3d9398234a54774544eb48a1caed6a1b2d5132eb (diff) | |
| download | fund-lab-b8811922f4e167d50efb8817da35a12d8a9d653b.tar.gz | |
Add redemption order minimal closed loop (3d-3)
Redeem confirmed holdings end to end with exact decimal semantics:
submission freezes position units (reserved_units) after an
available-units check without touching cash, confirmation settles at
the trade-date NAV with evidence and deferral rules identical to
subscriptions, credits the fund with proceeds net of the stated fee,
and writes off position cost pro rata (full redemption removes the
position row). Cash conservation holds at every step: frozen shares
and receivable cash never appear as available cash before settlement.
Domain gains RedemptionPolicy (request validation plus proceeds/fee/
cost-release computation) with unit tests. Persistence adds
redemption_orders, idempotency tables, reserved_units on positions
and Create/Confirm repository members; the API exposes
POST/GET /funds/{id}/redemptions and POST .../confirm. The frontend
adds a 06/REDEEM panel (submit, confirm, pending reasons, confirmed
proceeds/cost display), frozen-share display on the holdings panel,
and refresh wiring. Browser QA gains a K-series covering freeze,
settlement, cash accounting, replay and over-redemption rejection.
Diffstat (limited to 'qa/driver')
| -rw-r--r-- | qa/driver/browser-test.js | 124 |
1 files changed, 122 insertions, 2 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 46a8f85..677ae54 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -11,6 +11,8 @@ const orderPosts = []; const orderPostStatuses = []; const confirmPosts = []; const confirmPostStatuses = []; +const redemptionPosts = []; +const redemptionConfirmResponses = []; let positionGets = 0; let lastPositionsResponse = null; let fundPostResponses = 0; @@ -20,6 +22,7 @@ let createdFund = null; let createdFundResponse = null; let lastOrdersResponse = null; let lastConfirmResponse = null; +let lastRedemptionConfirmResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -106,7 +109,12 @@ async function summaryLabelExists(page, label, name) { } catch {} } if (r.url().includes("/orders") && r.request().method() === "POST") orderPostStatuses.push(r.status()); - if (r.url().includes("/confirm") && r.request().method() === "POST") { + if (r.url().includes("/confirm") && r.url().includes("/redemptions") && r.request().method() === "POST") { + try { + lastRedemptionConfirmResponse = await r.json(); + redemptionConfirmResponses.push(r.status()); + } catch {} + } else if (r.url().includes("/confirm") && r.request().method() === "POST") { confirmPostStatuses.push(r.status()); try { lastConfirmResponse = await r.json(); @@ -118,7 +126,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())) { + if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); @@ -132,6 +140,9 @@ async function summaryLabelExists(page, label, name) { if (r.url().includes("/confirm") && r.method() === "POST") { confirmPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "" }); } + 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() || "" }); + } }); page.on("console", (m) => { if (/^DBG /.test(m.text())) console.log("[dbg] " + m.text()); @@ -155,11 +166,120 @@ async function summaryLabelExists(page, label, name) { await idempotentRetryScenario(page); await orderScenario(page); await confirmScenario(page); + await redeemScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); } + +async function redeemScenario(page) { + // K 系列: 赎回提交(冻结份额) -> 确认(现金到账+份额核销),接 J 系列持仓 84.01949252 + await page.waitForSelector(".redeem-panel", { timeout: 10000 }); + const redeemEyebrow = ((await page.textContent(".redeem-panel .eyebrow")) || "").trim(); + check("K1 赎回面板 eyebrow 为 06 / REDEEM", redeemEyebrow === "06 / REDEEM", redeemEyebrow); + + await page.fill(".redemption-code-input", "000001"); + await page.fill(".redemption-units-input", "34.01949252"); + await page.fill(".redemption-fee-input", "1.00"); + await page.click(".redemption-submit-action"); + await page.waitForSelector(".pending-redemptions .order-row", { timeout: 15000 }); + const submittedRedemption = ((await page.textContent(".pending-redemptions")) || ""); + check( + "K2 提交赎回后显示已提交·待确认", + submittedRedemption.includes("已提交 · 待确认") && submittedRedemption.includes("份额 34.01949252"), + submittedRedemption.slice(0, 200) + ); + + // 提交后: 份额冻结但总份额与现金不变 + await page.waitForFunction( + () => { + const panel = document.querySelector(".positions-panel"); + return panel !== null && panel.textContent.includes("冻结份额 34.01949252"); + }, + null, + { timeout: 15000 } + ); + const frozenCash = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim())); + check( + "K3 提交后冻结份额且现金不变", + frozenCash.includes("4898.51") && frozenCash.includes("0.00"), + JSON.stringify(frozenCash) + ); + + await page.click(".redemption-confirm-action"); + for (let i = 0; i < 200 && (redemptionConfirmResponses.length === 0 || lastRedemptionConfirmResponse === null); i++) { + await page.waitForTimeout(100); + } + const redemptionStatus = lastRedemptionConfirmResponse ? lastRedemptionConfirmResponse.status : ""; + check("K4 赎回确认响应为 confirmed", redemptionStatus === "confirmed", redemptionStatus); + check( + "K5 赎回确认展示 NAV 与到账金额精确字符串", + lastRedemptionConfirmResponse.confirmedNav === "1.19020000" && + /^[0-9]+\.[0-9]{2}$/.test(lastRedemptionConfirmResponse.confirmedProceeds || "") && + /^[0-9]+\.[0-9]{2}$/.test(lastRedemptionConfirmResponse.confirmedCostReleased || ""), + JSON.stringify(lastRedemptionConfirmResponse).slice(0, 220) + ); + + await page.waitForFunction( + () => { + const panel = document.querySelector(".positions-panel"); + return panel !== null && panel.textContent.includes("份额 50.00000000"); + }, + null, + { timeout: 15000 } + ); + const proceeds = parseFloat(lastRedemptionConfirmResponse.confirmedProceeds); + const costReleased = parseFloat(lastRedemptionConfirmResponse.confirmedCostReleased); + const expectedAvailable = (4898.51 + proceeds).toFixed(2); + const expectedCost = (99.99 - costReleased).toFixed(2); + await page.waitForFunction( + (want) => { + const values = Array.from(document.querySelectorAll(".positions-panel .fund-detail-value")).map((el) => el.textContent.trim()); + return values.includes(want.available); + }, + { available: expectedAvailable }, + { timeout: 15000 } + ); + const settledCash = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim())); + const positionText = ((await page.textContent(".positions-panel")) || ""); + check( + "K6 确认后份额核销且现金按应收入账", + positionText.includes("份额 50.00000000") && + !positionText.includes("冻结份额") && + positionText.includes(expectedCost) && + settledCash.includes(expectedAvailable) && + settledCash.includes("0.00"), + JSON.stringify({ positionText: positionText.slice(0, 220), settledCash, expectedAvailable, expectedCost }) + ); + + const redemptionRowText = ((await page.textContent(".pending-redemptions")) || ""); + check( + "K7 赎回订单行显示已确认与到账金额", + redemptionRowText.includes("已确认") && redemptionRowText.includes(`赎回到账 ${lastRedemptionConfirmResponse.confirmedProceeds}`), + redemptionRowText.slice(0, 240) + ); + + // 份额上限校验: 超额赎回 409 且不产生冻结 + await page.fill(".redemption-units-input", "999999999.00000000"); + await page.fill(".redemption-fee-input", "0.00"); + await page.click(".redemption-submit-action"); + await page.waitForSelector(".error-banner", { timeout: 15000 }); + const insufficientText = ((await page.textContent(".error-banner")) || "").trim(); + check( + "K8 超额赎回被 409 拒绝", + insufficientText.includes("available holdings are not enough"), + insufficientText.slice(0, 140) + ); + const frozenAfterConflict = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim())); + check( + "K9 超额赎回后现金与冻结不变", + frozenAfterConflict.includes(expectedAvailable) && !((await page.textContent(".positions-panel")) || "").includes("冻结份额"), + JSON.stringify(frozenAfterConflict) + ); + await page.screenshot({ path: SHOTS + "/13-redemption-settled.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); |
