Files
console-dev/tests/review-purchase.e2e.test.mjs

443 lines
15 KiB
JavaScript

/** 购买回归走真实配置页、实例动作和支付界面;只替换已配置后端的 HTTP 响应。 */
import assert from "node:assert/strict";
import test from "node:test";
import {
appOrigin,
backendOrigin,
eventually,
fixtureRequest,
login,
scenario,
} from "./helpers/console-browser.mjs";
async function purchaseBoundary(page, { savedCascade } = {}) {
const state = {
authorized: false,
orders: new Map(),
created: [],
quotes: [],
carts: [],
settlements: [],
requests: [],
};
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")
return respond({
list:
savedCascade === undefined
? []
: [
{
product_id: 123,
qty: 1,
config_options: {
cycle: "free",
cascade_configoption: {
5: { item_id: savedCascade, quantity: 1 },
},
},
customfield: {},
self_defined_field: {},
},
],
});
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,
};
});
},
);