659 lines
22 KiB
JavaScript
659 lines
22 KiB
JavaScript
/** 购买回归走真实配置页、实例动作和支付界面;只替换已配置后端的 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 };
|
|
}),
|
|
);
|