Files
console-dev/tests/review-online-catalog.e2e.test.mjs

437 lines
13 KiB
JavaScript

/** 商品目录回归走公开页面及真实配置器,只在既有后端 HTTP 边界提供生产形状数据。 */
import assert from "node:assert/strict";
import test from "node:test";
import {
appOrigin,
backendOrigin,
eventually,
scenario,
} from "./helpers/console-browser.mjs";
async function catalogBoundary(page) {
const state = { productReads: [], quotes: [] };
const variants = [
{
id: 201,
name: "未控库存",
stock_control: 0,
qty: 0,
sync: 0,
modelQty: 0,
},
{
id: 202,
name: "商品售罄",
stock_control: 1,
qty: 0,
sync: 0,
modelQty: 8,
},
{
id: 203,
name: "共同约束",
stock_control: 1,
qty: 8,
sync: 1,
modelQty: 3,
},
{
id: 204,
name: "型号约束",
stock_control: 0,
qty: 0,
sync: 1,
modelQty: 4,
},
{
id: 205,
name: "型号售罄",
stock_control: 1,
qty: 8,
sync: 1,
modelQty: 0,
},
{
id: 206,
name: "库存待确认",
stock_control: 1,
qty: null,
sync: 0,
modelQty: 8,
},
{
id: 207,
name: "商品限制更小",
stock_control: 1,
qty: 2,
sync: 1,
modelQty: 9,
},
{
id: 208,
name: "仅商品限制",
stock_control: 1,
qty: 5,
sync: 0,
modelQty: 0,
},
];
const product = (entry) => ({
...entry,
module: "mf_dcim",
description: "",
price: "350.00",
cycle: "月",
product_group_id_second: 7,
});
const lineOptions = [
{
id: 11,
name: "阶梯带宽",
bill_type: "bw",
bw: [
{ type: "step", value: "0", min_value: 30, max_value: 30, step: 1 },
{ type: "step", value: "0", min_value: 31, max_value: 1000, step: 1 },
],
},
{
id: 12,
name: "固定带宽",
bill_type: "bw",
bw: [{ type: "radio", value: "100", min_value: 999, max_value: 999 }],
},
{
id: 13,
name: "区间流量",
bill_type: "flow",
flow: [
{
type: "total",
value: "0",
min_value: 500,
max_value: 2000,
step: 100,
},
],
},
];
// 每个请求仍经过 useApi 的真实配置/解析链,未替换任何项目内部模块。
await page
.context()
.route(`${backendOrigin}/console/v1/**`, async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace(/^\/console\/v1/, "");
const reply = (data) =>
route.fulfill({
json: { status: 200, msg: "", data },
headers: { "Access-Control-Allow-Origin": "*" },
});
if (path === "/product/group/first")
return reply({ list: [{ id: 6, name: "国内云服务器" }] });
if (path === "/product/group/second")
return reply({ list: [{ id: 7, parent_id: 6, name: "测试线路" }] });
if (path === "/product")
return reply({ list: variants.map(product), count: variants.length });
const match = /^\/product\/([^/]+)(.*)$/.exec(path);
if (!match) return route.fallback();
const id = Number(match[1]);
const suffix = match[2];
if (request.method() === "GET") state.productReads.push(path);
if (!suffix) {
const entry = variants.find((entry) => entry.id === id);
return reply({
product: entry
? product(entry)
: {
id,
module: "mf_dcim",
order_allowed: false,
order_block_reason: "边界商品已停止订购",
},
});
}
const entry = variants.find((entry) => entry.id === id) ?? variants[0];
if (suffix === "/mf_dcim/order_page")
return reply({
model_config: [
{
id,
name: `${entry.name}机型`,
cpu: "8核",
memory: "16G",
disk: "480G SSD",
qty: entry.modelQty,
auto_sync_dcim_stock: entry.sync,
},
],
data_center: [
{
id: 1,
name: "中国",
city: [
{
id: 2,
name: "测试地区",
area: [
{
id: 3,
name: "测试机房",
line: lineOptions.map(({ id, name, bill_type }) => ({
id,
name,
bill_type,
data_center_id: 1,
})),
},
],
},
],
},
],
});
const line = /^\/mf_dcim\/line\/(\d+)$/.exec(suffix);
if (line)
return reply({
...lineOptions.find((entry) => entry.id === Number(line[1])),
ip: [{ type: "radio", value: "1", desc: "1个" }],
defence: [{ type: "radio", value: "0", desc: "基础防护" }],
});
if (suffix === "/mf_dcim/image")
return reply({
list: [
{
id: 1,
name: "Ubuntu",
image: [
{
id: 10,
name: "Ubuntu 24.04",
image_group_id: 1,
price: "0.00",
},
],
},
],
});
if (suffix === "/self_defined_field/order_page")
return reply({ data: [] });
if (suffix === "/mf_dcim/duration")
return reply([{ id: 1, name: "月付", price: "350.00", is_default: 1 }]);
if (suffix === "/config_option") {
state.quotes.push(request.postDataJSON());
return reply({ price_total: "350.00" });
}
return route.fallback();
});
return state;
}
test(
"catalog stock labels and eligibility share effective product and model limits",
{ timeout: 60000 },
async (t) => {
await scenario(t, {}, async (page) => {
const state = await catalogBoundary(page);
await page.goto(`${appOrigin}/products/dedicated-server`);
const card = (name) =>
page.locator(".hardware-card").filter({
has: page.getByRole("heading", {
name: `${name}机型`,
exact: true,
}),
});
await card("未控库存").waitFor();
assert.equal(
await card("未控库存").getByText("剩余 0 台", { exact: true }).count(),
0,
"Uncontrolled placeholder zero is not an available-stock count",
);
assert.equal(
await card("未控库存")
.getByRole("button", { name: "选择线路", exact: true })
.isEnabled(),
true,
);
for (const name of ["商品售罄", "型号售罄"])
assert.equal(
await card(name)
.getByRole("button", { name: "库存不足", exact: true })
.isDisabled(),
true,
);
await card("共同约束").getByText("剩余 3 台", { exact: true }).waitFor();
await card("型号约束").getByText("剩余 4 台", { exact: true }).waitFor();
await card("商品限制更小")
.getByText("剩余 2 台", { exact: true })
.waitFor();
await card("仅商品限制")
.getByText("剩余 5 台", { exact: true })
.waitFor();
assert.equal(
await card("库存待确认")
.getByText("剩余 0 台", { exact: true })
.count(),
0,
"Missing quantity must not be coerced into advertised zero stock",
);
await page.goto(
`${appOrigin}/products/dedicated-server/customize?id=203&model_id=203&line_id=11`,
);
await page.getByText("当前库存 3 台", { exact: true }).waitFor();
assert.equal(
await page
.getByLabel("购买数量", { exact: true })
.getAttribute("aria-valuemax"),
"3",
);
const beforeSoldOut = state.quotes.length;
await page.goto(
`${appOrigin}/products/dedicated-server/customize?id=202&model_id=202&line_id=11`,
);
await page
.getByText("所选型号库存不足,请减少数量或选择其他型号")
.first()
.waitFor();
assert.equal(
state.quotes.length,
beforeSoldOut,
"Direct configuration of controlled zero stock must not request a quote",
);
return {
uncontrolledZeroNotAdvertised: true,
controlledZeroBlocked: true,
jointMinimumStock: 3,
modelOnlyStock: 4,
productSmallerStock: 2,
productOnlyStock: 5,
unknownNotTreatedAsZero: true,
configurationMatchesCatalog: true,
directSoldOutConfigurationDoesNotQuote: true,
};
});
},
);
test(
"catalog radio and ranged network summaries agree with configurator defaults",
{ timeout: 60000 },
async (t) => {
await scenario(t, {}, async (page) => {
const state = await catalogBoundary(page);
const observations = [];
for (const [lineId, name, expected, field, value] of [
[11, "阶梯带宽", "30 Mbps", "bw", "30"],
[12, "固定带宽", "100 Mbps", "bw", "100"],
[13, "区间流量", "500 GB", "flow", "500"],
]) {
await page.goto(`${appOrigin}/products/dedicated-server`);
await page
.locator(".hardware-card")
.filter({
has: page.getByRole("heading", {
name: "未控库存机型",
exact: true,
}),
})
.getByRole("button", { name: "选择线路", exact: true })
.click();
const line = page.locator(".line-card").filter({
has: page.locator(".line-tags").getByText(name, { exact: true }),
});
await line.locator(".line-specs").waitFor();
assert.equal(
(await line.innerText()).includes(expected),
true,
"Network summary must show a selectable value, not the range placeholder",
);
const before = state.quotes.length;
await line.getByRole("link", { name: "立即订购", exact: true }).click();
await eventually(
() => state.quotes.slice(before),
(quotes) =>
quotes.some(
(quote) =>
quote.config_options.line_id === lineId &&
quote.config_options[field] === value,
),
"Configurator quote must use the same valid network value as the catalog",
);
observations.push({
option: name,
summary: expected,
configuredValue: value,
});
}
return { networkOptions: observations };
});
},
);
test(
"invalid product identifiers show public errors before product API reads",
{ timeout: 90000 },
async (t) => {
await scenario(t, {}, async (page) => {
const state = await catalogBoundary(page);
const invalid = [
"2147483648",
"999999999999999999999999999999999",
"0",
"-1",
"01",
"not-a-product",
];
const observed = [];
for (const id of invalid) {
for (const path of [
`/products/${id}`,
`/products/dedicated-server/customize?id=${id}`,
`/products/cloud-server?id=${id}`,
`/products/cvm?product_id=${id}`,
]) {
const before = state.productReads.length;
await page.goto(appOrigin + path);
await eventually(
async () =>
state.productReads.length > before ||
(await page.getByText("商品编号无效").first().isVisible()),
Boolean,
"Identifier must either be rejected visibly or reach the observed external boundary",
);
assert.deepEqual(
state.productReads.slice(before),
[],
"Invalid identifier must not enter a product endpoint",
);
await page.getByText("商品编号无效").first().waitFor();
assert.equal(
new URL(page.url()).pathname,
path.split("?")[0],
"Invalid public product identifiers must not redirect to login",
);
assert.equal(
state.productReads.length,
before,
"Invalid identifier must not enter a product endpoint",
);
}
observed.push(id);
}
await page.goto(`${appOrigin}/products/2147483647`);
await page.getByText("边界商品已停止订购", { exact: true }).waitFor();
assert.equal(
state.productReads.includes("/product/2147483647"),
true,
"Largest supported signed 32-bit product identifier remains valid",
);
return {
invalidIdentifiersRejected: observed,
publicErrorNotLoginLoop: true,
invalidProductApiReads: 0,
largestValidIdentifierAccepted: true,
};
});
},
);