/** 购买回归走真实配置页、实例动作和支付界面;只替换已配置后端的 HTTP 响应。 */ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import test from "node:test"; import { appOrigin, backendOrigin, eventually, fixtureRequest, login, scenario, } from "./helpers/console-browser.mjs"; async function purchaseBoundary( page, { savedCascade, missingCartRevision = false, rejectCartWrites = 0 } = {}, ) { const state = { authorized: false, orders: new Map(), created: [], quotes: [], carts: [], settlements: [], requests: [], cartReads: 0, cartRevisions: [], cartWrites: [], cartRows: savedCascade === undefined ? [] : [ { product_id: 123, qty: 1, config_options: { cycle: "free", cascade_configoption: { 5: { item_id: savedCascade, quantity: 1 }, }, }, customfield: {}, self_defined_field: {}, }, ], }; const revision = () => createHash("sha256").update(JSON.stringify(state.cartRows)).digest("hex"); const tree = [ { item_name: "隐藏区域", hidden: 1, children: [{ item_id: 10, item_name: "不应售出的节点", is_leaf: 1 }], }, { item_name: "常规区域", children: [ { item_id: 20, item_name: "可选节点", is_leaf: 1 }, { item_id: 21, item_name: "隐藏节点", is_leaf: 1, hidden: 1 }, ], }, { item_id: 30, item_name: "备用区域", is_leaf: 1 }, ]; // 拦截点与生产 apiBase 一致;非本场景接口继续交给现有独立 HTTP 假后端。 await page .context() .route(`${backendOrigin}/console/v1/**`, async (route) => { const request = route.request(); const path = new URL(request.url()).pathname.replace( /^\/console\/v1/, "", ); const method = request.method(); const body = request.postDataJSON(); const reply = (data, extra = {}) => route.fulfill({ json: { status: 200, msg: "", data, ...extra }, headers: { "Access-Control-Allow-Origin": "*" }, }); const record = () => state.requests.push({ method, path, authenticated: !!request.headers().authorization, }); const respond = (data, extra) => { record(); return reply(data, extra); }; if (path === "/host/5") return respond({ host: { id: 5, product_id: 123, module: "mf_cloud", name: "本地镜像购买实例", status: "Active", billing_cycle: "monthly", upgrade_allowed: false, }, }); if (path === "/host/5/view") return respond({ module: "mf_cloud" }); if (path === "/mf_cloud/5") return respond({ image: { id: 6, name: "Ubuntu 24.04" }, port: 22, username: "root", cpu: 2, memory: 4, config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 }, }); if (path === "/mf_cloud/5/status") return respond({ status: "on", rescue: false }); if (path === "/mf_cloud/5/disk") return respond({ list: [] }); if (path === "/mf_cloud/5/flow") return respond({ total: 100, used: 0, leave: 100 }); if (path === "/mf_cloud/5/real_data") return respond({ cpu: 0, memory: 0, disk: 0 }); if (path === "/mf_cloud/5/chart") return respond({ list: [] }); if (path === "/product/123/mf_cloud/image") return respond({ list: [ { id: 1, name: "Ubuntu", image: [ { id: 6, name: "Ubuntu 24.04", image_group_id: 1, charge: 1, price: "10.00", }, ], }, ], }); if (path === "/mf_cloud/5/image/check") return respond({ price: state.authorized ? "0.00" : "10.00" }); if (path === "/mf_cloud/5/image/order") { const id = 42 + state.created.length; state.created.push(id); state.orders.set(id, "Unpaid"); return respond({ id }); } if (path === "/mf_cloud/5/reinstall") return respond({}); const order = /^\/order\/(\d+)$/.exec(path); if (order) { const id = Number(order[1]); if (method === "DELETE") { state.orders.set(id, "Cancelled"); return respond({}); } const status = state.orders.get(id) ?? "Unpaid"; return respond({ order: { id, amount: "10.00", amount_unpaid: status === "Paid" ? "0.00" : "10.00", credit: "0.00", type: "new", status, items: [{ id: 1, product_name: "系统镜像", amount: "10.00" }], }, customfields: { is_pre_invoice: 0 }, }); } if (/^\/order\/\d+\/transaction_record$/.test(path)) return respond({ list: [] }); if (path === "/pay") { state.orders.set(Number(body.id), "Paid"); return respond({}, { code: "Paid" }); } if (path === "/product/123") return respond({ product: { id: 123, name: "本地级联配置", module: "idcsmart_common", description: "", }, }); if (path === "/idcsmart_common/product/123/configoption") return respond({ common_product: { pay_type: "free" }, configoptions: [ { id: 5, option_name: "区域", option_type: "cascade", is_show: 1, tree, }, ], }); if (path === "/product/123/self_defined_field/order_page") return respond({ data: [] }); if (path === "/purchase_limit/check") return respond({ allowed: true, message: "" }); if (path === "/product/123/config_option") { state.quotes.push(body); return respond({ price_total: "0.00" }); } if (path === "/cart" && method === "POST") { state.carts.push(body); return respond({}); } if (path === "/cart" && method === "GET") { state.cartReads++; state.cartRevisions.push(revision()); return respond({ list: state.cartRows.map((row) => ({ ...row, name: "本地级联配置" })), ...(missingCartRevision ? {} : { revision: revision() }), }); } if (method === "PUT" && /^\/cart\/\d+(\/qty)?$/.test(path)) { const currentRevision = revision(); state.cartWrites.push({ method, path, body, cartReads: state.cartReads, revision: currentRevision, }); const status = rejectCartWrites || (!body?.expected_revision ? 428 : body.expected_revision !== currentRevision ? 409 : 200); if (status !== 200) return respond({}, { status, msg: "购物车状态待重新确认" }); const position = Number(path.split("/")[2]); state.cartRows[position] = path.endsWith("/qty") ? { ...state.cartRows[position], qty: body.qty } : { product_id: body.product_id, qty: body.qty, config_options: body.config_options, customfield: body.customfield, self_defined_field: body.self_defined_field, }; return respond({ revision: revision() }); } if (path === "/product/settle") { state.settlements.push(body); return respond({ order_id: 42, amount: "0.00" }); } return route.fallback(); }); return state; } test( "image retry reuses unpaid orders and replaces only a cancelled order", { timeout: 60000 }, async (t) => { await scenario(t, {}, async (page) => { const state = await purchaseBoundary(page); await login(page, "/servers/server?id=5"); await page.getByRole("tab", { name: "访问控制", exact: true }).click(); await page.getByRole("button", { name: "重装系统", exact: true }).click(); await page .getByRole("dialog", { name: "重装系统", exact: true }) .getByRole("button", { name: "确认", exact: true }) .click(); await page .getByRole("dialog", { name: "确认重装系统", exact: true }) .getByRole("button", { name: "确认重装", exact: true }) .click(); const purchase = page.getByRole("dialog", { name: "购买系统镜像", exact: true, }); const continuePurchase = () => purchase .getByRole("button", { name: "确认购买 / 继续支付", exact: true }) .click(); const payment = page.getByRole("dialog", { name: "订单支付", exact: true, }); const closePayment = async () => { await payment.getByRole("button", { name: /^(关闭|Close)$/ }).click(); await payment.waitFor({ state: "hidden" }); }; await continuePurchase(); await payment.waitFor(); await closePayment(); await continuePurchase(); await payment.waitFor(); assert.deepEqual( state.created, [42], "Retrying an unpaid order must not create a second order", ); await closePayment(); // 另一标签使用真实订单取消入口,原实例页保持挂载,不用刷新掩盖缓存生命周期。 const orderPage = await page.context().newPage(); await orderPage.goto(`${appOrigin}/orders/42`); await orderPage .getByRole("button", { name: "取消订单", exact: true }) .click(); await orderPage .getByRole("dialog", { name: "取消订单", exact: true }) .getByRole("button", { name: "确认取消", exact: true }) .click(); await eventually( () => state.orders.get(42), (status) => status === "Cancelled", "Second tab must cancel the first order", ); await orderPage.close(); await continuePurchase(); await eventually( () => state.created.length, (count) => count === 2, "Cancelled cached order must be replaced without reloading the instance", ); await payment.waitFor(); assert.deepEqual(state.created, [42, 43]); await payment .getByRole("button", { name: "确认支付", exact: true }) .click(); await eventually( () => state.orders.get(43), (status) => status === "Paid", "Payment must complete through the real payment panel", ); await purchase.waitFor(); await purchase .getByText("镜像授权尚未生效,请稍后重新检查", { exact: true }) .waitFor(); const paidRetryMessage = purchase.getByText( "镜像订单已支付,授权尚未生效,请稍后重新检查", { exact: true }, ); assert.equal(await paidRetryMessage.count(), 0); const readsBeforePaidRetry = state.requests.filter( (request) => request.method === "GET" && request.path === "/order/43", ).length; await continuePurchase(); // 本次 GET 增量和新文案共同证明重试已完成,不能沿用支付回调的旧提示或按钮状态。 await eventually( () => state.requests.filter( (request) => request.method === "GET" && request.path === "/order/43", ).length, (count) => count > readsBeforePaidRetry, "Paid order retry must fetch fresh server state", ); await paidRetryMessage.waitFor(); assert.deepEqual( state.created, [42, 43], "Paid but not-yet-authorized orders must not create another order", ); assert.equal(await payment.isVisible(), false); state.authorized = true; await continuePurchase(); await purchase.waitFor({ state: "hidden" }); assert.deepEqual(state.created, [42, 43]); assert.equal( state.requests.filter((request) => request.path.endsWith("/reinstall")) .length, 0, "Paying for an image must not automatically reinstall the instance", ); assert.ok(state.requests.every((request) => request.authenticated)); assert.ok( (await fixtureRequest("requests")).some( (request) => request.path === "/login", ), ); return { unpaidOrderReused: true, cancelledOrderReplaced: true, paidOrderRetainedUntilAuthorized: true, createdOrders: state.created.length, automaticReinstalls: 0, }; }); }, ); test( "cascade defaults, visible choices and submitted values agree", { timeout: 40000 }, async (t) => { await scenario(t, {}, async (page) => { const state = await purchaseBoundary(page); await login(page, "/products/123"); await eventually( () => state.quotes.length, (count) => count > 0, "Configuration must receive a real quote", ); assert.equal( state.quotes.at(-1).config_options.cascade_configoption[5].item_id, 20, "Default must skip descendants of hidden ancestors", ); const region = page.getByRole("combobox", { name: "区域", exact: true }); assert.match(await region.innerText(), /常规区域.*可选节点/); await region.click(); const labels = await page.getByRole("option").allTextContents(); assert.equal(labels.length, 2); assert.ok( labels.every( (label) => !label.includes("隐藏") && !label.includes("不应售出"), ), ); const quotesBeforeSelection = state.quotes.length; await page.getByRole("option", { name: "备用区域", exact: true }).click(); // 改选非默认项并等本轮新报价;选择处理若失效,不能沿用默认 20 的报价假绿。 await eventually( () => state.quotes .slice(quotesBeforeSelection) .some( (quote) => quote.config_options.cascade_configoption[5].item_id === 30, ), Boolean, "Selecting a non-default visible candidate must request a fresh quote", ); assert.match(await region.innerText(), /备用区域/); await page .getByRole("button", { name: "加入购物车", exact: true }) .click(); await eventually( () => state.carts.length, (count) => count === 1, "Cart must receive the selected visible candidate", ); assert.equal( state.carts[0].config_options.cascade_configoption[5].item_id, 30, ); await page.getByRole("button", { name: "立即购买", exact: true }).click(); await eventually( () => state.settlements.length, (count) => count === 1, "Checkout must submit the same visible candidate", ); assert.equal( state.settlements[0].config_options.cascade_configoption[5].item_id, 30, ); return { visibleChoices: labels.length, defaultVisibleItem: 20, cartVisibleItem: 30, checkoutVisibleItem: 30, }; }); }, ); test( "cart editing does not restore a hidden cascade descendant", { timeout: 40000 }, async (t) => { await scenario(t, {}, async (page) => { const state = await purchaseBoundary(page, { savedCascade: 10 }); await login(page, "/products/123?edit_position=0"); await page .getByRole("button", { name: "重新加载配置", exact: true }) .waitFor(); assert.equal( state.quotes.length, 0, "An unavailable saved candidate must not receive a valid quote", ); assert.equal(state.settlements.length, 0); assert.equal(state.carts.length, 0); return { hiddenSavedCandidateRejected: true, quotesForHiddenCandidate: 0, }; }); }, ); test( "cart configuration keeps the original revision without fetching a replacement before saving", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { const state = await purchaseBoundary(page, { savedCascade: 20 }); await login(page, "/products/123?edit_position=0"); const save = page.getByRole("button", { name: "保存配置", exact: true }); await eventually( () => save.isEnabled(), Boolean, "编辑配置必须先取得有效报价", ); await save.click(); await eventually( () => state.cartWrites.length, (count) => count === 1, "配置只能提交一次", ); const write = state.cartWrites[0]; assert.equal(write.body.expected_revision, write.revision); assert.equal(write.cartReads, 1); assert.equal(Object.hasOwn(write.body, "expected_item"), false); assert.equal(Object.hasOwn(write.body, "expected_cart"), false); await page.waitForURL((url) => url.pathname === "/cart"); return { originalRevisionSubmitted: true, readsBeforeSave: write.cartReads, configurationWrites: 1, }; }), ); for (const status of [409, 428]) { test( `cart editing stops after server ${status} and requires returning to the cart`, { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { const state = await purchaseBoundary(page, { savedCascade: 20, rejectCartWrites: status === 428 ? status : 0, }); await login(page, "/products/123?edit_position=0"); const save = page.getByRole("button", { name: "保存配置", exact: true, }); await eventually( () => save.isEnabled(), Boolean, "编辑配置必须先取得有效报价", ); const originalRevision = state.cartRevisions[0]; // 另一会话只改动兄弟项,当前商品仍相同,也不能把新版本套在旧位置上。 if (status === 409) state.cartRows.push({ ...state.cartRows[0], qty: 2 }); await save.click(); await eventually( () => state.cartWrites.length, (count) => count === 1, "冲突只允许一次写入尝试", ); await page.locator(".p-message-error").waitFor(); assert.equal(new URL(page.url()).pathname, "/products/123"); assert.equal(state.cartReads, 1); assert.equal( state.cartWrites[0].body.expected_revision, originalRevision, ); if (status === 409) assert.notEqual(state.cartWrites[0].revision, originalRevision); await page .locator(".product-links") .getByRole("link", { name: "购物车", exact: true }) .click(); await page.waitForURL((url) => url.pathname === "/cart"); await eventually( () => state.cartReads, (count) => count === 2, "返回购物车后重新读取可确认的列表", ); assert.equal(state.cartWrites.length, 1); return { rejectedStatus: status, configurationWrites: 1, readsOnlyAfterReturning: 2, }; }), ); test( `cart server ${status} clears the selection without retrying a positional mutation`, { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { const state = await purchaseBoundary(page, { savedCascade: 20, rejectCartWrites: status, }); await login(page, "/cart"); const selected = page.getByRole("checkbox", { name: "选择 本地级联配置", exact: true, }); await selected.waitFor(); assert.equal(await selected.isChecked(), true); await page .getByRole("button", { name: "增加 本地级联配置 的数量", exact: true, }) .click(); await eventually( () => state.cartWrites.length, (count) => count === 1, "修改数量只提交一次", ); await eventually( () => selected.isChecked(), (checked) => !checked, "冲突后必须重新选择商品确认", ); assert.equal( state.cartWrites[0].body.expected_revision, state.cartWrites[0].revision, ); assert.equal( Object.hasOwn(state.cartWrites[0].body, "expected_cart"), false, ); assert.equal( await page .getByRole("button", { name: "提交订单", exact: true }) .isDisabled(), true, ); assert.equal(state.cartWrites.length, 1); return { rejectedStatus: status, mutationAttempts: 1, selectionCleared: true, }; }), ); } test( "a cart response without a revision offers reload and cannot mutate old positions", { timeout: 30000 }, (t) => scenario(t, {}, async (page) => { const state = await purchaseBoundary(page, { savedCascade: 20, missingCartRevision: true, }); await login(page, "/cart"); await page .getByRole("button", { name: "重新加载", exact: true }) .waitFor(); assert.equal(await page.locator(".cart-item").count(), 0); assert.equal( await page .getByRole("button", { name: "提交订单", exact: true }) .count(), 0, ); assert.equal(state.cartWrites.length, 0); return { missingRevisionBlocked: true, mutationAttempts: 0 }; }), );